diff --git a/.vscode/settings.json b/.vscode/settings.json index 35f1a0328c..889622973f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "solidity.defaultCompiler": "localNodeModule", "solidity.packageDefaultDependenciesContractsDirectory": "contracts/contracts", "solidity.packageDefaultDependenciesDirectory": "contracts/node_modules", "solidity.compileUsingRemoteVersion": "v0.8.7+commit.e28d00a7", diff --git a/contracts/contracts/interfaces/IUniswapV3Strategy.sol b/contracts/contracts/interfaces/IUniswapV3Strategy.sol new file mode 100644 index 0000000000..259a34cce4 --- /dev/null +++ b/contracts/contracts/interfaces/IUniswapV3Strategy.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +import { IStrategy } from "./IStrategy.sol"; + +interface IUniswapV3Strategy is IStrategy { + function token0() external view returns (address); + + function token1() external view returns (address); + + function reserveStrategy(address token) external view returns (address); + + function closeActivePositionOnlyVault() external; +} diff --git a/contracts/contracts/interfaces/IVault.sol b/contracts/contracts/interfaces/IVault.sol index db5c7166ab..1596fc85e4 100644 --- a/contracts/contracts/interfaces/IVault.sol +++ b/contracts/contracts/interfaces/IVault.sol @@ -76,8 +76,12 @@ interface IVault { function approveStrategy(address _addr) external; + function approveUniswapV3Strategy(address _addr) external; + function removeStrategy(address _addr) external; + function isStrategySupported(address _addr) external view returns (bool); + function setAssetDefaultStrategy(address _asset, address _strategy) external; @@ -170,4 +174,9 @@ interface IVault { function setNetOusdMintForStrategyThreshold(uint256 _threshold) external; function netOusdMintedForStrategy() external view returns (int256); + + function depositToUniswapV3Reserve(address asset, uint256 amount) external; + + function withdrawFromUniswapV3Reserve(address asset, uint256 amount) + external; } diff --git a/contracts/contracts/interfaces/IVaultValueChecker.sol b/contracts/contracts/interfaces/IVaultValueChecker.sol new file mode 100644 index 0000000000..8174da9e21 --- /dev/null +++ b/contracts/contracts/interfaces/IVaultValueChecker.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +interface IVaultValueChecker { + function takeSnapshot() external; + + function checkDelta( + int256 lowValueDelta, + int256 highValueDelta, + int256 lowSupplyDelta, + int256 highSupplyDelta + ) external; +} diff --git a/contracts/contracts/interfaces/uniswap/v3/INonfungiblePositionManager.sol b/contracts/contracts/interfaces/uniswap/v3/INonfungiblePositionManager.sol new file mode 100644 index 0000000000..c117bbbf15 --- /dev/null +++ b/contracts/contracts/interfaces/uniswap/v3/INonfungiblePositionManager.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +/// @title Non-fungible token for positions +/// @notice Wraps Uniswap V3 positions in a non-fungible token interface which allows for them to be transferred +/// and authorized. +interface INonfungiblePositionManager { + /// @notice Returns the position information associated with a given token ID. + /// @dev Throws if the token ID is not valid. + /// @param tokenId The ID of the token that represents the position + /// @return nonce The nonce for permits + /// @return operator The address that is approved for spending + /// @return token0 The address of the token0 for a specific pool + /// @return token1 The address of the token1 for a specific pool + /// @return fee The fee associated with the pool + /// @return tickLower The lower end of the tick range for the position + /// @return tickUpper The higher end of the tick range for the position + /// @return liquidity The liquidity of the position + /// @return feeGrowthInside0LastX128 The fee growth of token0 as of the last action on the individual position + /// @return feeGrowthInside1LastX128 The fee growth of token1 as of the last action on the individual position + /// @return tokensOwed0 The uncollected amount of token0 owed to the position as of the last computation + /// @return tokensOwed1 The uncollected amount of token1 owed to the position as of the last computation + function positions(uint256 tokenId) + external + view + returns ( + uint96 nonce, + address operator, + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ); + + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + /// @notice Creates a new position wrapped in a NFT + /// @dev Call this when the pool does exist and is initialized. Note that if the pool is created but not initialized + /// a method does not exist, i.e. the pool is assumed to be initialized. + /// @param params The params necessary to mint a position, encoded as `MintParams` in calldata + /// @return tokenId The ID of the token that represents the minted position + /// @return liquidity The amount of liquidity for this position + /// @return amount0 The amount of token0 + /// @return amount1 The amount of token1 + function mint(MintParams calldata params) + external + payable + returns ( + uint256 tokenId, + uint128 liquidity, + uint256 amount0, + uint256 amount1 + ); + + struct IncreaseLiquidityParams { + uint256 tokenId; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + /// @notice Increases the amount of liquidity in a position, with tokens paid by the `msg.sender` + /// @param params tokenId The ID of the token for which liquidity is being increased, + /// amount0Desired The desired amount of token0 to be spent, + /// amount1Desired The desired amount of token1 to be spent, + /// amount0Min The minimum amount of token0 to spend, which serves as a slippage check, + /// amount1Min The minimum amount of token1 to spend, which serves as a slippage check, + /// deadline The time by which the transaction must be included to effect the change + /// @return liquidity The new liquidity amount as a result of the increase + /// @return amount0 The amount of token0 to acheive resulting liquidity + /// @return amount1 The amount of token1 to acheive resulting liquidity + function increaseLiquidity(IncreaseLiquidityParams calldata params) + external + payable + returns ( + uint128 liquidity, + uint256 amount0, + uint256 amount1 + ); + + struct DecreaseLiquidityParams { + uint256 tokenId; + uint128 liquidity; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + /// @notice Decreases the amount of liquidity in a position and accounts it to the position + /// @param params tokenId The ID of the token for which liquidity is being decreased, + /// amount The amount by which liquidity will be decreased, + /// amount0Min The minimum amount of token0 that should be accounted for the burned liquidity, + /// amount1Min The minimum amount of token1 that should be accounted for the burned liquidity, + /// deadline The time by which the transaction must be included to effect the change + /// @return amount0 The amount of token0 accounted to the position's tokens owed + /// @return amount1 The amount of token1 accounted to the position's tokens owed + function decreaseLiquidity(DecreaseLiquidityParams calldata params) + external + payable + returns (uint256 amount0, uint256 amount1); + + struct CollectParams { + uint256 tokenId; + address recipient; + uint128 amount0Max; + uint128 amount1Max; + } + + /// @notice Collects up to a maximum amount of fees owed to a specific position to the recipient + /// @param params tokenId The ID of the NFT for which tokens are being collected, + /// recipient The account that should receive the tokens, + /// amount0Max The maximum amount of token0 to collect, + /// amount1Max The maximum amount of token1 to collect + /// @return amount0 The amount of fees collected in token0 + /// @return amount1 The amount of fees collected in token1 + function collect(CollectParams calldata params) + external + payable + returns (uint256 amount0, uint256 amount1); + + /// @notice Burns a token ID, which deletes it from the NFT contract. The token must have 0 liquidity and all tokens + /// must be collected first. + /// @param tokenId The ID of the token that is being burned + function burn(uint256 tokenId) external payable; +} diff --git a/contracts/contracts/interfaces/uniswap/v3/IUniswapV3Helper.sol b/contracts/contracts/interfaces/uniswap/v3/IUniswapV3Helper.sol new file mode 100644 index 0000000000..38a4108992 --- /dev/null +++ b/contracts/contracts/interfaces/uniswap/v3/IUniswapV3Helper.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +import { INonfungiblePositionManager } from "./INonfungiblePositionManager.sol"; + +interface IUniswapV3Helper { + function getAmountsForLiquidity( + uint160 sqrtRatioX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity + ) external view returns (uint256 amount0, uint256 amount1); + + function getLiquidityForAmounts( + uint160 sqrtRatioX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint256 amount0, + uint256 amount1 + ) external view returns (uint128 liquidity); + + function getSqrtRatioAtTick(int24 tick) + external + view + returns (uint160 sqrtPriceX96); + + function positionFees( + INonfungiblePositionManager positionManager, + address poolAddress, + uint256 tokenId + ) external view returns (uint256 amount0, uint256 amount1); + + function positionTotal( + INonfungiblePositionManager positionManager, + address poolAddress, + uint256 tokenId, + uint160 sqrtRatioX96 + ) external view returns (uint256 amount0, uint256 amount1); + + function positionPrincipal( + INonfungiblePositionManager positionManager, + uint256 tokenId, + uint160 sqrtRatioX96 + ) external view returns (uint256 amount0, uint256 amount1); +} diff --git a/contracts/contracts/mocks/MockStrategy.sol b/contracts/contracts/mocks/MockStrategy.sol new file mode 100644 index 0000000000..ca39d89b9d --- /dev/null +++ b/contracts/contracts/mocks/MockStrategy.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MockStrategy { + using SafeERC20 for IERC20; + + address public vaultAddress; + mapping(address => bool) public supportsAsset; + address[] internal allAssets; + + modifier onlyVault() { + require(msg.sender == vaultAddress, "Not vault"); + _; + } + + constructor(address _vaultAddress, address[] memory assets) { + vaultAddress = _vaultAddress; + for (uint256 i = 0; i < assets.length; i++) { + address asset = assets[i]; + supportsAsset[asset] = true; + allAssets.push(asset); + } + } + + function deposit(address _asset, uint256 _amount) public onlyVault { + // Do nothing + } + + function depositAll() public onlyVault { + // Do nothing + } + + function withdraw(address _asset, uint256 _amount) public onlyVault { + IERC20(_asset).safeTransfer(vaultAddress, _amount); + } + + function withdraw( + address _recipient, + address _asset, + uint256 _amount + ) external onlyVault { + IERC20(_asset).safeTransfer(_recipient, _amount); + } + + function checkBalance(address _asset) + external + view + returns (uint256 balance) + { + balance = IERC20(_asset).balanceOf(address(this)); + } + + function withdrawAll() public onlyVault { + for (uint256 i = 0; i < allAssets.length; i++) { + IERC20 asset = IERC20(allAssets[i]); + asset.safeTransfer(vaultAddress, asset.balanceOf(address(this))); + } + } + + function collectRewardTokens() external { + // Do nothing + } + + function getRewardTokenAddresses() + external + view + returns (address[] memory) + { + return new address[](0); + } +} diff --git a/contracts/contracts/mocks/MockMintableUniswapPair.sol b/contracts/contracts/mocks/uniswap/v2/MockMintableUniswapPair.sol similarity index 93% rename from contracts/contracts/mocks/MockMintableUniswapPair.sol rename to contracts/contracts/mocks/uniswap/v2/MockMintableUniswapPair.sol index 342f074a8e..6cf74c542e 100644 --- a/contracts/contracts/mocks/MockMintableUniswapPair.sol +++ b/contracts/contracts/mocks/uniswap/v2/MockMintableUniswapPair.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "./MintableERC20.sol"; +import "./../../MintableERC20.sol"; import "./MockUniswapPair.sol"; contract MockMintableUniswapPair is MockUniswapPair, MintableERC20 { diff --git a/contracts/contracts/mocks/MockUniswapPair.sol b/contracts/contracts/mocks/uniswap/v2/MockUniswapPair.sol similarity index 97% rename from contracts/contracts/mocks/MockUniswapPair.sol rename to contracts/contracts/mocks/uniswap/v2/MockUniswapPair.sol index 7030e1e746..504abc6f84 100644 --- a/contracts/contracts/mocks/MockUniswapPair.sol +++ b/contracts/contracts/mocks/uniswap/v2/MockUniswapPair.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import { IUniswapV2Pair } from "../interfaces/uniswap/IUniswapV2Pair.sol"; +import { IUniswapV2Pair } from "../../../interfaces/uniswap/IUniswapV2Pair.sol"; contract MockUniswapPair is IUniswapV2Pair { address tok0; diff --git a/contracts/contracts/mocks/MockUniswapRouter.sol b/contracts/contracts/mocks/uniswap/v2/MockUniswapRouter.sol similarity index 74% rename from contracts/contracts/mocks/MockUniswapRouter.sol rename to contracts/contracts/mocks/uniswap/v2/MockUniswapRouter.sol index 5f57948fbd..30f3d9bcd4 100644 --- a/contracts/contracts/mocks/MockUniswapRouter.sol +++ b/contracts/contracts/mocks/uniswap/v2/MockUniswapRouter.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.0; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IUniswapV2Router } from "../interfaces/uniswap/IUniswapV2Router02.sol"; -import { Helpers } from "../utils/Helpers.sol"; -import { StableMath } from "../utils/StableMath.sol"; +import { IUniswapV2Router } from "../../../interfaces/uniswap/IUniswapV2Router02.sol"; +import { Helpers } from "../../../utils/Helpers.sol"; +import { StableMath } from "../../../utils/StableMath.sol"; // import "hardhat/console.sol"; @@ -121,4 +121,36 @@ contract MockUniswapRouter is IUniswapV2Router { function WETH() external pure override returns (address) { return address(0); } + + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + function exactInputSingle(ExactInputSingleParams calldata params) + external + payable + returns (uint256 amountOut) + { + amountOut = params.amountIn.scaleBy( + Helpers.getDecimals(params.tokenIn), + Helpers.getDecimals(params.tokenOut) + ); + IERC20(params.tokenIn).transferFrom( + msg.sender, + address(this), + params.amountIn + ); + IERC20(params.tokenOut).transfer(params.recipient, amountOut); + require( + amountOut >= params.amountOutMinimum, + "UniswapMock: amountOut less than amountOutMinimum" + ); + } } diff --git a/contracts/contracts/mocks/uniswap/v3/MockNonfungiblePositionManager.sol b/contracts/contracts/mocks/uniswap/v3/MockNonfungiblePositionManager.sol new file mode 100644 index 0000000000..5e3ca804f3 --- /dev/null +++ b/contracts/contracts/mocks/uniswap/v3/MockNonfungiblePositionManager.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IUniswapV3Helper } from "../../../interfaces/uniswap/v3/IUniswapV3Helper.sol"; +import { IMockUniswapV3Pool } from "./MockUniswapV3Pool.sol"; +// solhint-disable-next-line no-console +import "hardhat/console.sol"; + +contract MockNonfungiblePositionManager { + using SafeERC20 for IERC20; + + uint128 public mockTokensOwed0; + uint128 public mockTokensOwed1; + + struct MockPosition { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint128 token0Owed; + uint128 token1Owed; + uint128 liquidity; + address recipient; + } + + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + struct IncreaseLiquidityParams { + uint256 tokenId; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + struct DecreaseLiquidityParams { + uint256 tokenId; + uint128 liquidity; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + struct CollectParams { + uint256 tokenId; + address recipient; + uint128 amount0Max; + uint128 amount1Max; + } + + mapping(uint256 => MockPosition) public mockPositions; + + uint256 public slippage = 100; + + IUniswapV3Helper internal helper; + IMockUniswapV3Pool internal mockPool; + + uint256 internal tokenCount = 0; + + constructor(address _helper, address _mockPool) { + helper = IUniswapV3Helper(_helper); + mockPool = IMockUniswapV3Pool(_mockPool); + } + + function positions(uint256 tokenId) + external + view + returns ( + uint96 nonce, + address operator, + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ) + { + MockPosition memory p = mockPositions[tokenId]; + return ( + 0, + address(0), + address(0), + address(0), + p.fee, + p.tickLower, + p.tickUpper, + p.liquidity, + 0, + 0, + p.token0Owed, + p.token1Owed + ); + } + + function setTokensOwed( + uint256 tokenId, + uint128 token0, + uint128 token1 + ) public { + MockPosition storage p = mockPositions[tokenId]; + p.token0Owed = token0; + p.token1Owed = token1; + } + + function mint(MintParams calldata params) + external + payable + returns ( + uint256 tokenId, + uint128 liquidity, + uint256 amount0, + uint256 amount1 + ) + { + tokenCount += 1; + tokenId = tokenCount; + + mockPositions[tokenId] = MockPosition({ + token0: params.token0, + token1: params.token1, + recipient: params.recipient, + token0Owed: 0, + token1Owed: 0, + liquidity: 0, + fee: params.fee, + tickLower: params.tickLower, + tickUpper: params.tickUpper + }); + + MockPosition storage p = mockPositions[tokenId]; + + (liquidity) = helper.getLiquidityForAmounts( + mockPool.mockSqrtPriceX96(), + helper.getSqrtRatioAtTick(p.tickLower), + helper.getSqrtRatioAtTick(p.tickUpper), + params.amount0Desired, + params.amount1Desired + ); + + (amount0, amount1) = helper.getAmountsForLiquidity( + mockPool.mockSqrtPriceX96(), + helper.getSqrtRatioAtTick(p.tickLower), + helper.getSqrtRatioAtTick(p.tickUpper), + liquidity + ); + + IERC20(params.token0).safeTransferFrom( + msg.sender, + address(this), + amount0 + ); + IERC20(params.token1).safeTransferFrom( + msg.sender, + address(this), + amount1 + ); + + p.liquidity += liquidity; + + require(amount0 >= params.amount0Min, "V3 Liquidity error"); + require(amount1 >= params.amount1Min, "V3 Liquidity error"); + } + + function increaseLiquidity(IncreaseLiquidityParams calldata params) + external + payable + returns ( + uint128 liquidity, + uint256 amount0, + uint256 amount1 + ) + { + MockPosition storage p = mockPositions[params.tokenId]; + + (liquidity) = helper.getLiquidityForAmounts( + mockPool.mockSqrtPriceX96(), + helper.getSqrtRatioAtTick(p.tickLower), + helper.getSqrtRatioAtTick(p.tickUpper), + params.amount0Desired, + params.amount1Desired + ); + + (amount0, amount1) = helper.getAmountsForLiquidity( + mockPool.mockSqrtPriceX96(), + helper.getSqrtRatioAtTick(p.tickLower), + helper.getSqrtRatioAtTick(p.tickUpper), + liquidity + ); + + IERC20(p.token0).safeTransferFrom(msg.sender, address(this), amount0); + IERC20(p.token1).safeTransferFrom(msg.sender, address(this), amount1); + + p.liquidity += liquidity; + + require(amount0 >= params.amount0Min, "V3 Liquidity error"); + require(amount1 >= params.amount1Min, "V3 Liquidity error"); + } + + function decreaseLiquidity(DecreaseLiquidityParams calldata params) + external + payable + returns (uint256 amount0, uint256 amount1) + { + MockPosition storage p = mockPositions[params.tokenId]; + + amount0 = params.amount0Min; + amount1 = params.amount1Min; + + IMintableERC20(p.token0).mint(amount0); + IMintableERC20(p.token1).mint(amount1); + + IERC20(p.token0).safeTransfer(p.recipient, amount0); + IERC20(p.token1).safeTransfer(p.recipient, amount1); + + p.liquidity -= params.liquidity; + } + + function collect(CollectParams calldata params) + external + payable + returns (uint256 amount0, uint256 amount1) + { + MockPosition storage p = mockPositions[params.tokenId]; + + amount0 = p.token0Owed; + amount1 = p.token1Owed; + + IMintableERC20(p.token0).mint(amount0); + IMintableERC20(p.token1).mint(amount1); + + IERC20(p.token0).safeTransfer(p.recipient, amount0); + IERC20(p.token1).safeTransfer(p.recipient, amount1); + + p.token0Owed = 0; + p.token1Owed = 0; + } + + function setSlippage(uint256 _slippage) public { + slippage = _slippage; + } +} + +interface IMintableERC20 { + function mint(uint256 value) external; + + function mintTo(address to, uint256 value) external; +} diff --git a/contracts/contracts/mocks/uniswap/v3/MockUniswapV3Pool.sol b/contracts/contracts/mocks/uniswap/v3/MockUniswapV3Pool.sol new file mode 100644 index 0000000000..295990df33 --- /dev/null +++ b/contracts/contracts/mocks/uniswap/v3/MockUniswapV3Pool.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +import { IUniswapV3Helper } from "../../../interfaces/uniswap/v3/IUniswapV3Helper.sol"; +import { ISwapRouter } from "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; + +contract MockUniswapV3Pool { + address public immutable token0; + address public immutable token1; + uint24 public immutable fee; + + uint160 public mockSqrtPriceX96; + int24 public mockTick; + IUniswapV3Helper internal helper; + + constructor( + address _token0, + address _token1, + uint24 _fee, + address _helper + ) { + token0 = _token0; + token1 = _token1; + fee = _fee; + helper = IUniswapV3Helper(_helper); + } + + function slot0() + public + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint8 feeProtocol, + bool unlocked + ) + { + return (mockSqrtPriceX96, mockTick, 0, 0, 0, 0, true); + } + + function setTick(int24 tick) public { + mockTick = tick; + mockSqrtPriceX96 = helper.getSqrtRatioAtTick(tick); + } + + function setVal(uint160 sqrtPriceX96, int24 tick) public { + mockSqrtPriceX96 = sqrtPriceX96; + mockTick = tick; + } + + function ticks(int24 tick) + public + view + returns ( + uint128 liquidityGross, + int128 liquidityNet, + uint256 feeGrowthOutside0X128, + uint256 feeGrowthOutside1X128, + int56 tickCumulativeOutside, + uint160 secondsPerLiquidityOutsideX128, + uint32 secondsOutside, + bool initialized + ) + { + // + } + + function feeGrowthGlobal0X128() public view returns (uint256) { + return 0; + } + + function feeGrowthGlobal1X128() public view returns (uint256) { + return 0; + } +} + +interface IMockUniswapV3Pool { + function setTick(int24 tick) external; + + function mockSqrtPriceX96() external returns (uint160); +} diff --git a/contracts/contracts/proxies/Proxies.sol b/contracts/contracts/proxies/Proxies.sol index e7ac9fe1b4..f98ef930fb 100644 --- a/contracts/contracts/proxies/Proxies.sol +++ b/contracts/contracts/proxies/Proxies.sol @@ -88,12 +88,19 @@ contract ConvexLUSDMetaStrategyProxy is InitializeGovernedUpgradeabilityProxy { } /** - * @notice MorphoAaveStrategyProxy delegates calls to a MorphoCompoundStrategy implementation + * @notice MorphoAaveStrategyProxy delegates calls to a MorphoAaveStrategy implementation */ contract MorphoAaveStrategyProxy is InitializeGovernedUpgradeabilityProxy { } +/** + * @notice UniV3_USDC_USDT_Proxy delegates calls to a UniswapV3Strategy implementation + */ +contract UniV3_USDC_USDT_Proxy is InitializeGovernedUpgradeabilityProxy { + +} + /** * @notice OETHProxy delegates calls to nowhere for now */ diff --git a/contracts/contracts/strategies/uniswap/README.md b/contracts/contracts/strategies/uniswap/README.md new file mode 100644 index 0000000000..0ac339a4d8 --- /dev/null +++ b/contracts/contracts/strategies/uniswap/README.md @@ -0,0 +1,15 @@ +# Diagrams + +## Uniswap V3 Strategy + +### Hierarchy + +![Uniswap V3 Strategy Hierarchy](../../../docs/UniswapV3StrategyHierarchy.svg) + +### Squashed + +![Uniswap V3 Strategy Squashed](../../../docs/UniswapV3StrategySquashed.svg) + +### Storage + +![Uniswap V3 Strategy Storage](../../../docs/UniswapV3StrategyStorage.svg) diff --git a/contracts/contracts/strategies/uniswap/UniswapV3LiquidityManager.sol b/contracts/contracts/strategies/uniswap/UniswapV3LiquidityManager.sol new file mode 100644 index 0000000000..d5401ca79d --- /dev/null +++ b/contracts/contracts/strategies/uniswap/UniswapV3LiquidityManager.sol @@ -0,0 +1,950 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +import { UniswapV3StrategyStorage } from "./UniswapV3StrategyStorage.sol"; + +import { INonfungiblePositionManager } from "../../interfaces/uniswap/v3/INonfungiblePositionManager.sol"; +import { IVault } from "../../interfaces/IVault.sol"; +import { ISwapRouter } from "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Helpers } from "../../utils/Helpers.sol"; +import { StableMath } from "../../utils/StableMath.sol"; + +contract UniswapV3LiquidityManager is UniswapV3StrategyStorage { + using SafeERC20 for IERC20; + using StableMath for uint256; + + /*************************************** + Position Value + ****************************************/ + /** + * @notice Calculates the net value of the position excluding fees + * @param tokenId tokenID of the Position NFT + * @return posValue Value of position (in 18 decimals) + */ + function _getPositionValue(uint256 tokenId) + internal + view + returns (uint256 posValue) + { + (uint256 amount0, uint256 amount1) = getPositionPrincipal(tokenId); + + posValue = _getValueOfTokens(amount0, amount1); + } + + /** + * @notice Calculates the net value of the token amounts (assumes it's pegged to $1) + * @param amount0 Amount of token0 + * @param amount1 Amount of token1 + * @return value Net value (in 18 decimals) + */ + function _getValueOfTokens(uint256 amount0, uint256 amount1) + internal + view + returns (uint256 value) + { + value += amount0.scaleBy(18, Helpers.getDecimals(token0)); + value += amount1.scaleBy(18, Helpers.getDecimals(token1)); + } + + /*************************************** + Rebalance + ****************************************/ + /// Reverts if active position's value is greater than maxTVL + function _ensureTVL() internal { + require( + _getPositionValue(activeTokenId) <= maxTVL, + "MaxTVL threshold has been reached" + ); + } + + /** + * @notice Reverts if swaps are paused or if swapping constraints aren't met. + * @param sqrtPriceLimitX96 Desired swap price limit + * @param swapZeroForOne True when swapping token0 for token1 + */ + function _swapsNotPausedAndWithinLimits( + uint160 sqrtPriceLimitX96, + bool swapZeroForOne + ) internal { + require(!swapsPaused, "Swaps are paused"); + + (uint160 currentPriceX96, , , , , , ) = pool.slot0(); + + require( + minSwapPriceX96 <= currentPriceX96 && + currentPriceX96 <= maxSwapPriceX96, + "Price out of bounds" + ); + + require( + swapZeroForOne + ? (sqrtPriceLimitX96 >= minSwapPriceX96) + : (sqrtPriceLimitX96 <= maxSwapPriceX96), + "Slippage out of bounds" + ); + } + + /// Reverts if rebalances are paused + function _rebalanceNotPaused() internal { + require(!rebalancePaused, "Rebalances are paused"); + } + + /** + * @notice Reverts if rebalances are paused or if rebalance constraints aren't met. + * @param upperTick Upper tick index + * @param lowerTick Lower tick inded + */ + function _rebalanceNotPausedAndWithinLimits( + int24 lowerTick, + int24 upperTick + ) internal { + require(!rebalancePaused, "Rebalances are paused"); + require( + minRebalanceTick <= lowerTick && maxRebalanceTick >= upperTick, + "Rebalance position out of bounds" + ); + } + + /** + * @notice Update the netLostValue state. + * + * If the value of position increase multiple times without a drop in value, + * the counter would still be at zero. We don't keep track of any value gained + * until the counter is > 0, as the only purpose of this state variable is + * to shut off rebalancing if the LP positions are losing capital across rebalances. + * + * @param delta The unsigned change in value + * @param gained True, if sign of delta is positive + */ + function _setNetLostValue(uint256 delta, bool gained) internal { + if (delta == 0) { + // No change + return; + } + + if (gained) { + if (netLostValue == 0) { + // No change + return; + } else if (delta >= netLostValue) { + // Reset lost value + netLostValue = 0; + } else { + // Deduct gained amount from netLostValue + netLostValue -= delta; + } + } else { + // Add lost value + netLostValue += delta; + } + + emit NetLostValueChanged(netLostValue); + } + + /** + * @notice Computes the current value of the given token and updates + * the storage. Also, updates netLostValue state + * @param tokenId Token ID of the position + */ + function _updatePositionNetVal(uint256 tokenId) internal { + if (tokenId == 0) { + return; + } + + uint256 currentVal = _getPositionValue(tokenId); + uint256 lastVal = tokenIdToPosition[tokenId].netValue; + + if (currentVal == lastVal) { + // No change in value + return; + } + + if (currentVal > lastVal) { + _setNetLostValue(currentVal - lastVal, true); + } else { + _setNetLostValue(lastVal - currentVal, false); + } + + // NOTE: Intentionally skipped passing the `int256 delta` to `_setNetLostValue`, + // Wanna be safe about uint() to int() conversions. + emit PositionValueChanged( + tokenId, + lastVal, + currentVal, + int256(currentVal) - int256(lastVal) + ); + + // Update state + tokenIdToPosition[tokenId].netValue = currentVal; + } + + /** + * @notice Updates the value of the current position. + * Reverts if netLostValue threshold is breached. + * @param tokenId Token ID of the position + */ + function _ensureNetValueLostThreshold(uint256 tokenId) internal { + _updatePositionNetVal(tokenId); + require( + netLostValue < maxPositionValueLostThreshold, + "Over max value loss threshold" + ); + } + + /** + * @notice Closes active LP position if any and then provides liquidity to the requested position. + * Mints new position, if it doesn't exist already. If active position is on the same tick + * range, then just increases the liquidity by the desiredAmounts + * @dev Will pull funds needed from reserve strategies and then will deposit back all dust to them + * @param desiredAmount0 Amount of token0 to use to provide liquidity + * @param desiredAmount1 Amount of token1 to use to provide liquidity + * @param minAmount0 Min amount of token0 to deposit/expect + * @param minAmount1 Min amount of token1 to deposit/expect + * @param minRedeemAmount0 Min amount of token0 received from closing active position + * @param minRedeemAmount1 Min amount of token1 received from closing active position + * @param lowerTick Desired lower tick index + * @param upperTick Desired upper tick index + */ + function rebalance( + uint256 desiredAmount0, + uint256 desiredAmount1, + uint256 minAmount0, + uint256 minAmount1, + uint256 minRedeemAmount0, + uint256 minRedeemAmount1, + int24 lowerTick, + int24 upperTick + ) external onlyGovernorOrStrategistOrOperator nonReentrant { + require(lowerTick < upperTick, "Invalid tick range"); + _rebalanceNotPausedAndWithinLimits(lowerTick, upperTick); + + int48 tickKey = _getTickPositionKey(lowerTick, upperTick); + uint256 tokenId = ticksToTokenId[tickKey]; + + if (activeTokenId > 0 && activeTokenId != tokenId) { + // Close any active position (if it's not the same) + _closePosition(activeTokenId, minRedeemAmount0, minRedeemAmount1); + } + + // Withdraw enough funds from Reserve strategies + _ensureAssetBalances(desiredAmount0, desiredAmount1); + + // Provide liquidity + if (tokenId > 0) { + // Add liquidity to the position token + _increasePositionLiquidity( + tokenId, + desiredAmount0, + desiredAmount1, + minAmount0, + minAmount1 + ); + } else { + // Mint new position + (tokenId, , , ) = _mintPosition( + desiredAmount0, + desiredAmount1, + minAmount0, + minAmount1, + lowerTick, + upperTick + ); + } + + // Mark it as active position + activeTokenId = tokenId; + + // Move any leftovers to Reserve + _depositAll(); + + // Final position value/sanity check + _ensureTVL(); + } + + struct SwapAndRebalanceParams { + uint256 desiredAmount0; + uint256 desiredAmount1; + uint256 minAmount0; + uint256 minAmount1; + uint256 minRedeemAmount0; + uint256 minRedeemAmount1; + int24 lowerTick; + int24 upperTick; + uint256 swapAmountIn; + uint256 swapMinAmountOut; + uint160 sqrtPriceLimitX96; + bool swapZeroForOne; + } + + /** + * @notice Closes active LP position if any and then provides liquidity to the requested position. + * Mints new position, if it doesn't exist already. If active position is on the same tick + * range, then just increases the liquidity by the desiredAmounts. Will pull funds needed + * from reserve strategies and then will deposit back all dust to them + * @param params.desiredAmount0 Amount of token0 to use to provide liquidity + * @param params.desiredAmount1 Amount of token1 to use to provide liquidity + * @param params.minAmount0 Min amount of token0 to deposit/expect + * @param params.minAmount1 Min amount of token1 to deposit/expect + * @param params.minRedeemAmount0 Min amount of token0 received from closing active position + * @param params.minRedeemAmount1 Min amount of token1 received from closing active position + * @param params.lowerTick Desired lower tick index + * @param params.upperTick Desired upper tick index + * @param params.swapAmountIn Amount of tokens to swap + * @param params.swapMinAmountOut Minimum amount of other tokens expected + * @param params.sqrtPriceLimitX96 Max price limit for swap + * @param params.swapZeroForOne True if swapping from token0 to token1 + */ + function swapAndRebalance(SwapAndRebalanceParams calldata params) + external + onlyGovernorOrStrategistOrOperator + nonReentrant + { + require(params.lowerTick < params.upperTick, "Invalid tick range"); + _swapsNotPausedAndWithinLimits( + params.sqrtPriceLimitX96, + params.swapZeroForOne + ); + _rebalanceNotPausedAndWithinLimits(params.lowerTick, params.upperTick); + + uint256 tokenId = ticksToTokenId[ + _getTickPositionKey(params.lowerTick, params.upperTick) + ]; + + if (activeTokenId > 0 && activeTokenId != tokenId) { + // Close any active position (if it's not the same) + _closePosition( + activeTokenId, + params.minRedeemAmount0, + params.minRedeemAmount1 + ); + } + + // Withdraw enough funds from Reserve strategies and swap to desired amounts + _ensureAssetsBySwapping( + params.desiredAmount0, + params.desiredAmount1, + params.swapAmountIn, + params.swapMinAmountOut, + params.sqrtPriceLimitX96, + params.swapZeroForOne + ); + + // Provide liquidity + if (tokenId > 0) { + // Add liquidity to the position token + _increasePositionLiquidity( + tokenId, + params.desiredAmount0, + params.desiredAmount1, + params.minAmount0, + params.minAmount1 + ); + } else { + // Mint new position + (tokenId, , , ) = _mintPosition( + params.desiredAmount0, + params.desiredAmount1, + params.minAmount0, + params.minAmount1, + params.lowerTick, + params.upperTick + ); + } + + // Mark it as active position + activeTokenId = tokenId; + + // Move any leftovers to Reserve + _depositAll(); + + // Final position value/sanity check + _ensureTVL(); + } + + /*************************************** + Pool Liquidity Management + ****************************************/ + /** + * @notice Returns a unique ID based on lowerTick and upperTick + * @dev Basically concats the lower tick and upper tick values. Shifts the value + * of lowerTick by 24 bits and then adds the upperTick value to avoid overlaps. + * So, the result is smaller in size (int48 rather than bytes32 when using keccak256) + * @param lowerTick Lower tick index + * @param upperTick Upper tick index + * @return key A unique identifier to be used with ticksToTokenId + */ + function _getTickPositionKey(int24 lowerTick, int24 upperTick) + internal + returns (int48 key) + { + if (lowerTick > upperTick) + (lowerTick, upperTick) = (upperTick, lowerTick); + key = int48(lowerTick) * 2**24; // Shift by 24 bits + key = key + upperTick; + } + + /** + * @notice Mints a new position on the pool and provides liquidity to it + * + * @param desiredAmount0 Desired amount of token0 to provide liquidity + * @param desiredAmount1 Desired amount of token1 to provide liquidity + * @param minAmount0 Min amount of token0 to deposit + * @param minAmount1 Min amount of token1 to deposit + * @param lowerTick Lower tick index + * @param upperTick Upper tick index + * + * @return tokenId ERC721 token ID of the position minted + * @return liquidity Amount of liquidity added to the pool + * @return amount0 Amount of token0 added to the position + * @return amount1 Amount of token1 added to the position + */ + function _mintPosition( + uint256 desiredAmount0, + uint256 desiredAmount1, + uint256 minAmount0, + uint256 minAmount1, + int24 lowerTick, + int24 upperTick + ) + internal + returns ( + uint256 tokenId, + uint128 liquidity, + uint256 amount0, + uint256 amount1 + ) + { + int48 tickKey = _getTickPositionKey(lowerTick, upperTick); + require(ticksToTokenId[tickKey] == 0, "Duplicate position mint"); + + // Make sure liquidity management is disabled when value lost threshold is breached + _ensureNetValueLostThreshold(0); + + INonfungiblePositionManager.MintParams + memory params = INonfungiblePositionManager.MintParams({ + token0: token0, + token1: token1, + fee: poolFee, + tickLower: lowerTick, + tickUpper: upperTick, + amount0Desired: desiredAmount0, + amount1Desired: desiredAmount1, + amount0Min: minAmount0, + amount1Min: minAmount1, + recipient: address(this), + deadline: block.timestamp + }); + + (tokenId, liquidity, amount0, amount1) = positionManager.mint(params); + + require(!tokenIdToPosition[tokenId].exists, "Duplicate position"); + + ticksToTokenId[tickKey] = tokenId; + tokenIdToPosition[tokenId] = Position({ + exists: true, + tokenId: tokenId, + liquidity: liquidity, + lowerTick: lowerTick, + upperTick: upperTick, + sqrtRatioAX96: helper.getSqrtRatioAtTick(lowerTick), + sqrtRatioBX96: helper.getSqrtRatioAtTick(upperTick), + netValue: _getValueOfTokens(amount0, amount1) + }); + + emit UniswapV3PositionMinted(tokenId, lowerTick, upperTick); + emit UniswapV3LiquidityAdded(tokenId, amount0, amount1, liquidity); + } + + /** + * @notice Increases liquidity of the given token. + * + * @param tokenId Position NFT's tokenId + * @param desiredAmount0 Desired amount of token0 to provide liquidity + * @param desiredAmount1 Desired amount of token1 to provide liquidity + * @param minAmount0 Min amount of token0 to deposit + * @param minAmount1 Min amount of token1 to deposit + * + * @return liquidity Amount of liquidity added + * @return amount0 Amount of token0 deposited + * @return amount1 Amount of token1 deposited + */ + function _increasePositionLiquidity( + uint256 tokenId, + uint256 desiredAmount0, + uint256 desiredAmount1, + uint256 minAmount0, + uint256 minAmount1 + ) + internal + returns ( + uint128 liquidity, + uint256 amount0, + uint256 amount1 + ) + { + Position storage position = tokenIdToPosition[tokenId]; + require(position.exists, "No active position"); + + // Make sure liquidity management is disabled when value lost threshold is breached + _ensureNetValueLostThreshold(tokenId); + + INonfungiblePositionManager.IncreaseLiquidityParams + memory params = INonfungiblePositionManager + .IncreaseLiquidityParams({ + tokenId: position.tokenId, + amount0Desired: desiredAmount0, + amount1Desired: desiredAmount1, + amount0Min: minAmount0, + amount1Min: minAmount1, + deadline: block.timestamp + }); + + (liquidity, amount0, amount1) = INonfungiblePositionManager( + positionManager + ).increaseLiquidity(params); + + position.liquidity += liquidity; + // Update last known value + position.netValue = _getPositionValue(tokenId); + + emit UniswapV3LiquidityAdded(tokenId, amount0, amount1, liquidity); + } + + /** + * @notice Increases liquidity of the active position token. + * Will pull funds needed from reserve strategies if needed. + * + * @param desiredAmount0 Desired amount of token0 to provide liquidity + * @param desiredAmount1 Desired amount of token1 to provide liquidity + * @param minAmount0 Min amount of token0 to deposit + * @param minAmount1 Min amount of token1 to deposit + * + * @return amount0 Amount of token0 deposited + * @return amount1 Amount of token1 deposited + */ + function increaseActivePositionLiquidity( + uint256 desiredAmount0, + uint256 desiredAmount1, + uint256 minAmount0, + uint256 minAmount1 + ) + external + onlyGovernorOrStrategistOrOperator + nonReentrant + returns (uint256 amount0, uint256 amount1) + { + _rebalanceNotPaused(); + + // Withdraw enough funds from Reserve strategies + _ensureAssetBalances(desiredAmount0, desiredAmount1); + + (, amount0, amount1) = _increasePositionLiquidity( + activeTokenId, + desiredAmount0, + desiredAmount1, + minAmount0, + minAmount1 + ); + + // Deposit + _depositAll(); + + // Final position value/sanity check + _ensureTVL(); + } + + /** + * @notice Removes liquidity of the position in the pool + * + * @param tokenId Position NFT's tokenId + * @param liquidity Amount of liquidity to remove from the position + * @param minAmount0 Min amount of token0 to withdraw + * @param minAmount1 Min amount of token1 to withdraw + * + * @return amount0 Amount of token0 received after liquidation + * @return amount1 Amount of token1 received after liquidation + */ + function _decreasePositionLiquidity( + uint256 tokenId, + uint128 liquidity, + uint256 minAmount0, + uint256 minAmount1 + ) internal returns (uint256 amount0, uint256 amount1) { + Position storage position = tokenIdToPosition[tokenId]; + require(position.exists, "Unknown position"); + + // Update net value loss (to capture the state value before updating it). + // Also allows to close/decrease liquidity even if beyond the net loss threshold. + _updatePositionNetVal(tokenId); + + INonfungiblePositionManager.DecreaseLiquidityParams + memory params = INonfungiblePositionManager + .DecreaseLiquidityParams({ + tokenId: position.tokenId, + liquidity: liquidity, + amount0Min: minAmount0, + amount1Min: minAmount1, + deadline: block.timestamp + }); + + (amount0, amount1) = positionManager.decreaseLiquidity(params); + + position.liquidity -= liquidity; + // Update last known value + position.netValue = _getPositionValue(tokenId); + + emit UniswapV3LiquidityRemoved( + position.tokenId, + amount0, + amount1, + liquidity + ); + } + + /** + * @notice Removes liquidity of the active position in the pool + * + * @param liquidity Amount of liquidity to remove from the position + * @param minAmount0 Min amount of token0 to withdraw + * @param minAmount1 Min amount of token1 to withdraw + * + * @return amount0 Amount of token0 received after liquidation + * @return amount1 Amount of token1 received after liquidation + */ + function decreaseActivePositionLiquidity( + uint128 liquidity, + uint256 minAmount0, + uint256 minAmount1 + ) + external + onlyGovernorOrStrategistOrOperator + nonReentrant + returns (uint256 amount0, uint256 amount1) + { + _rebalanceNotPaused(); + + (amount0, amount1) = _decreasePositionLiquidity( + activeTokenId, + liquidity, + minAmount0, + minAmount1 + ); + + // Deposit + _depositAll(); + + // Intentionally skipping TVL check since removing liquidity won't cause it to fail + } + + /** + * @notice Closes the position denoted by the tokenId and and collects all fees + * @param tokenId Position NFT's tokenId + * @param minAmount0 Min amount of token0 to receive back + * @param minAmount1 Min amount of token1 to receive back + * @return amount0 Amount of token0 received after removing liquidity + * @return amount1 Amount of token1 received after removing liquidity + */ + function _closePosition( + uint256 tokenId, + uint256 minAmount0, + uint256 minAmount1 + ) internal returns (uint256 amount0, uint256 amount1) { + Position memory position = tokenIdToPosition[tokenId]; + require(position.exists, "Invalid position"); + + if (position.liquidity > 0) { + // Remove all liquidity + (amount0, amount1) = _decreasePositionLiquidity( + tokenId, + position.liquidity, + minAmount0, + minAmount1 + ); + } + + if (position.tokenId == activeTokenId) { + activeTokenId = 0; + } + + emit UniswapV3PositionClosed(position.tokenId, amount0, amount1); + + // Collect all fees for position + (uint256 amount0Fee, uint256 amount1Fee) = _collectFeesForToken( + position.tokenId + ); + + amount0 = amount0 + amount0Fee; + amount1 = amount1 + amount1Fee; + } + + /** + * @notice Closes the position denoted by the tokenId and and collects all fees + * @param tokenId Token ID of the position to collect fees of. + * @param minAmount0 Min amount of token0 to receive back + * @param minAmount1 Min amount of token1 to receive back + * @return amount0 Amount of token0 received after removing liquidity + * @return amount1 Amount of token1 received after removing liquidity + */ + function closePosition( + uint256 tokenId, + uint256 minAmount0, + uint256 minAmount1 + ) + external + onlyGovernorOrStrategistOrOperator + nonReentrant + returns (uint256 amount0, uint256 amount1) + { + (amount0, amount1) = _closePosition(tokenId, minAmount0, minAmount1); + + _depositAll(); + + // Intentionally skipping TVL check since removing liquidity won't cause it to fail + } + + /** + * @notice Same as closePosition but only callable by Vault + * @dev Doesn't have non-Reentrant modifier since it's supposed to be delegatecalled + * only from `UniswapV3Strategy.withdrawAll` which already has a nonReentrant check + * and the storage is shared between these two contract. + */ + function closeActivePositionOnlyVault() + external + onlyVault + returns (uint256 amount0, uint256 amount1) + { + // Since this is called by the Vault, we cannot pass min redeem amounts + // without complicating the code of the Vault. So, passing 0 instead. + (amount0, amount1) = _closePosition(activeTokenId, 0, 0); + + // Intentionally skipping TVL check since removing liquidity won't cause it to fail + } + + /*************************************** + Balances and Fees + ****************************************/ + /** + * @dev Checks if there's enough balance left in the contract to provide liquidity. + * If not, tries to pull it from reserve strategies + * @param desiredAmount0 Minimum amount of token0 needed + * @param desiredAmount1 Minimum amount of token1 needed + */ + function _ensureAssetBalances( + uint256 desiredAmount0, + uint256 desiredAmount1 + ) internal { + IVault vault = IVault(vaultAddress); + + // Withdraw enough funds from Reserve strategies + uint256 token0Balance = IERC20(token0).balanceOf(address(this)); + if (token0Balance < desiredAmount0) { + vault.withdrawFromUniswapV3Reserve( + token0, + desiredAmount0 - token0Balance + ); + } + + uint256 token1Balance = IERC20(token1).balanceOf(address(this)); + if (token1Balance < desiredAmount1) { + vault.withdrawFromUniswapV3Reserve( + token1, + desiredAmount1 - token1Balance + ); + } + } + + /** + * @dev Swaps one token for other and then provides liquidity to pools. + * + * @param desiredAmount0 Minimum amount of token0 needed + * @param desiredAmount1 Minimum amount of token1 needed + * @param swapAmountIn Amount of tokens to swap + * @param swapMinAmountOut Minimum amount of other tokens expected + * @param sqrtPriceLimitX96 Max price limit for swap + * @param swapZeroForOne True if swapping from token0 to token1 + */ + function _ensureAssetsBySwapping( + uint256 desiredAmount0, + uint256 desiredAmount1, + uint256 swapAmountIn, + uint256 swapMinAmountOut, + uint160 sqrtPriceLimitX96, + bool swapZeroForOne + ) internal { + require(!swapsPaused, "Swaps are paused"); + + uint256 token0Balance = IERC20(token0).balanceOf(address(this)); + uint256 token1Balance = IERC20(token1).balanceOf(address(this)); + + uint256 token0Needed = desiredAmount0 > token0Balance + ? desiredAmount0 - token0Balance + : 0; + uint256 token1Needed = desiredAmount1 > token1Balance + ? desiredAmount1 - token1Balance + : 0; + + if (swapZeroForOne) { + // Amount available in reserve strategies + uint256 t1ReserveBal = reserveStrategy1.checkBalance(token1); + + // Only swap when asset isn't available in reserve as well + require(token1Needed > 0, "No need for swap"); + require( + token1Needed > t1ReserveBal, + "Cannot swap when the asset is available in reserve" + ); + // Additional amount of token0 required for swapping + token0Needed += swapAmountIn; + // Subtract token1 that we will get from swapping + token1Needed = (swapMinAmountOut >= token1Needed) + ? 0 + : (token1Needed - swapMinAmountOut); + } else { + // Amount available in reserve strategies + uint256 t0ReserveBal = reserveStrategy0.checkBalance(token0); + + // Only swap when asset isn't available in reserve as well + require(token0Needed > 0, "No need for swap"); + require( + token0Needed > t0ReserveBal, + "Cannot swap when the asset is available in reserve" + ); + // Additional amount of token1 required for swapping + token1Needed += swapAmountIn; + // Subtract token0 that we will get from swapping + token0Needed = (swapMinAmountOut >= token0Needed) + ? 0 + : (token0Needed - swapMinAmountOut); + } + + // Fund strategy from reserve strategies + if (token0Needed > 0) { + IVault(vaultAddress).withdrawFromUniswapV3Reserve( + token0, + token0Needed + ); + } + + if (token1Needed > 0) { + IVault(vaultAddress).withdrawFromUniswapV3Reserve( + token1, + token1Needed + ); + } + + // Swap it + uint256 amountReceived = swapRouter.exactInputSingle( + ISwapRouter.ExactInputSingleParams({ + tokenIn: swapZeroForOne ? token0 : token1, + tokenOut: swapZeroForOne ? token1 : token0, + fee: poolFee, + recipient: address(this), + deadline: block.timestamp, + amountIn: swapAmountIn, + amountOutMinimum: swapMinAmountOut, + sqrtPriceLimitX96: sqrtPriceLimitX96 + }) + ); + + emit AssetSwappedForRebalancing( + swapZeroForOne ? token0 : token1, + swapZeroForOne ? token1 : token0, + swapAmountIn, + amountReceived + ); + } + + /** + * @notice Collects the fees generated by the position on V3 pool. + * Also adjusts netLostValue based on fee collected. + * @param tokenId Token ID of the position to collect fees of. + * @return amount0 Amount of token0 collected as fee + * @return amount1 Amount of token1 collected as fee + */ + function _collectFeesForToken(uint256 tokenId) + internal + returns (uint256 amount0, uint256 amount1) + { + require(tokenIdToPosition[tokenId].exists, "Invalid position"); + INonfungiblePositionManager.CollectParams + memory params = INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }); + + (amount0, amount1) = positionManager.collect(params); + + // Reset loss counter to include value of fee collected + _setNetLostValue(_getValueOfTokens(amount0, amount1), true); + + emit UniswapV3FeeCollected(tokenId, amount0, amount1); + } + + /** + * @notice Collects fees from the active LP position + * @return amount0 Amount of token0 collected as fee + * @return amount1 Amount of token1 collected as fee + */ + function collectFees() + external + onlyGovernorOrStrategistOrOperator + returns (uint256 amount0, uint256 amount1) + { + return _collectFeesForToken(activeTokenId); + } + + /*************************************** + Hidden functions + ****************************************/ + function _abstractSetPToken(address, address) internal override { + revert("NO_IMPL"); + } + + function safeApproveAllTokens() external override { + revert("NO_IMPL"); + } + + function deposit(address, uint256) external override { + revert("NO_IMPL"); + } + + function depositAll() external override { + revert("NO_IMPL"); + } + + function withdrawAll() external override { + revert("NO_IMPL"); + } + + function withdraw( + address, + address, + uint256 + ) external override { + revert("NO_IMPL"); + } + + function checkBalance(address) external view override returns (uint256) { + revert("NO_IMPL"); + } + + function supportsAsset(address) external view override returns (bool) { + revert("NO_IMPL"); + } + + function setPTokenAddress(address, address) external override { + revert("NO_IMPL"); + } + + function removePToken(uint256) external override { + revert("NO_IMPL"); + } + + function collectRewardTokens() external override { + revert("NO_IMPL"); + } +} diff --git a/contracts/contracts/strategies/uniswap/UniswapV3Strategy.sol b/contracts/contracts/strategies/uniswap/UniswapV3Strategy.sol new file mode 100644 index 0000000000..21f1230ef3 --- /dev/null +++ b/contracts/contracts/strategies/uniswap/UniswapV3Strategy.sol @@ -0,0 +1,541 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +import { InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; +import { UniswapV3StrategyStorage } from "./UniswapV3StrategyStorage.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IStrategy } from "../../interfaces/IStrategy.sol"; +import { IVault } from "../../interfaces/IVault.sol"; + +import { INonfungiblePositionManager } from "../../interfaces/uniswap/v3/INonfungiblePositionManager.sol"; +import { IUniswapV3Helper } from "../../interfaces/uniswap/v3/IUniswapV3Helper.sol"; +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; +import { IUniswapV3Strategy } from "../../interfaces/IUniswapV3Strategy.sol"; +import { ISwapRouter } from "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; + +contract UniswapV3Strategy is UniswapV3StrategyStorage { + using SafeERC20 for IERC20; + + /** + * @dev Initialize the contract + * @param _vaultAddress OUSD Vault + * @param _poolAddress Uniswap V3 Pool + * @param _nonfungiblePositionManager Uniswap V3's Position Manager + * @param _helper Deployed UniswapV3Helper contract + * @param _swapRouter Uniswap SwapRouter contract + * @param _operator Operator address + */ + function initialize( + address _vaultAddress, + address _poolAddress, + address _nonfungiblePositionManager, + address _helper, + address _swapRouter, + address _operator, + uint256 _maxTVL, + uint256 _maxValueLostThreshold + ) external onlyGovernor initializer { + // NOTE: _self should always be the address of the proxy. + // This is used to do `delegatecall` between the this contract and + // `UniswapV3LiquidityManager` whenever it's required. + _self = IUniswapV3Strategy(address(this)); + + pool = IUniswapV3Pool(_poolAddress); + helper = IUniswapV3Helper(_helper); + swapRouter = ISwapRouter(_swapRouter); + positionManager = INonfungiblePositionManager( + _nonfungiblePositionManager + ); + + token0 = pool.token0(); + token1 = pool.token1(); + poolFee = pool.fee(); + + address[] memory _assets = new address[](2); + _assets[0] = token0; + _assets[1] = token1; + + super._initialize( + _poolAddress, + _vaultAddress, + new address[](0), // No Reward tokens + _assets, // Asset addresses + _assets // Platform token addresses + ); + + _setOperator(_operator); + _setMaxTVL(_maxTVL); + _setMaxPositionValueLostThreshold(_maxValueLostThreshold); + } + + /*************************************** + Admin Utils + ****************************************/ + + /** + * @notice Change the address of the operator + * @dev Can only be called by the Governor or Strategist + * @param _operator The new value to be set + */ + function setOperator(address _operator) external onlyGovernorOrStrategist { + _setOperator(_operator); + } + + function _setOperator(address _operator) internal { + operatorAddr = _operator; + emit OperatorChanged(_operator); + } + + /** + * @notice Change the reserve strategy of the supported asset + * @dev Will throw if the strategies don't support the assets or if + * strategy is unsupported by the vault + * @param _asset Asset to set the reserve strategy for + * @param _reserveStrategy The new reserve strategy for token + */ + function setReserveStrategy(address _asset, address _reserveStrategy) + external + onlyGovernorOrStrategist + nonReentrant + { + _onlyPoolTokens(_asset); + + require( + IVault(vaultAddress).isStrategySupported(_reserveStrategy), + "Unsupported strategy" + ); + + require( + IStrategy(_reserveStrategy).supportsAsset(_asset), + "Invalid strategy for asset" + ); + + if (_asset == token0) { + reserveStrategy0 = IStrategy(_reserveStrategy); + } else if (_asset == token1) { + reserveStrategy1 = IStrategy(_reserveStrategy); + } + + emit ReserveStrategyChanged(_asset, _reserveStrategy); + } + + /** + * @notice Get reserve strategy of the given asset + * @param _asset Address of the asset + * @return reserveStrategyAddr Reserve strategy address + */ + function reserveStrategy(address _asset) + external + view + returns (address reserveStrategyAddr) + { + if (_asset == token0) { + reserveStrategyAddr = address(reserveStrategy0); + } else if (_asset == token1) { + reserveStrategyAddr = address(reserveStrategy1); + } + } + + /** + * @notice Change the minimum deposit threshold for the supported asset + * @param _asset Asset to set the threshold + * @param _minThreshold The new deposit threshold value + */ + function setMinDepositThreshold(address _asset, uint256 _minThreshold) + external + onlyGovernorOrStrategist + { + _onlyPoolTokens(_asset); + + if (_asset == token0) { + minDepositThreshold0 = _minThreshold; + } else if (_asset == token1) { + minDepositThreshold1 = _minThreshold; + } + emit MinDepositThresholdChanged(_asset, _minThreshold); + } + + /** + * @notice Toggle rebalance methods + * @param _paused True if rebalance has to be paused + */ + function setRebalancePaused(bool _paused) + external + onlyGovernorOrStrategist + { + rebalancePaused = _paused; + emit RebalancePauseStatusChanged(_paused); + } + + /** + * @notice Toggle swapAndRebalance method + * @param _paused True if swaps have to be paused + */ + function setSwapsPaused(bool _paused) external onlyGovernorOrStrategist { + swapsPaused = _paused; + emit SwapsPauseStatusChanged(_paused); + } + + /** + * @notice Change the maxTVL amount threshold + * @param _maxTVL Maximum amount the strategy can have deployed in the Uniswap pool + */ + function setMaxTVL(uint256 _maxTVL) external onlyGovernorOrStrategist { + _setMaxTVL(_maxTVL); + } + + /** + * @notice Change the maxTVL amount threshold + * @param _maxTVL Maximum amount the strategy can have deployed in the Uniswap pool + */ + function _setMaxTVL(uint256 _maxTVL) internal { + maxTVL = _maxTVL; + emit MaxTVLChanged(_maxTVL); + } + + /** + * @notice Maximum value of loss the LP positions can incur before strategy shuts off rebalances + * @param _maxValueLostThreshold Maximum amount in 18 decimals + */ + function setMaxPositionValueLostThreshold(uint256 _maxValueLostThreshold) + external + onlyGovernorOrStrategist + { + _setMaxPositionValueLostThreshold(_maxValueLostThreshold); + } + + /** + * @notice Maximum value of loss the LP positions can incur before strategy shuts off rebalances + * @param _maxValueLostThreshold Maximum amount in 18 decimals + */ + function _setMaxPositionValueLostThreshold(uint256 _maxValueLostThreshold) + internal + { + maxPositionValueLostThreshold = _maxValueLostThreshold; + emit MaxValueLostThresholdChanged(_maxValueLostThreshold); + } + + /** + * @notice Reset loss counter + * @dev Only governor can call it + */ + function resetLostValue() external onlyGovernor { + emit NetLostValueReset(msg.sender); + emit NetLostValueChanged(0); + netLostValue = 0; + } + + /** + * @notice Change the rebalance price threshold + * @param minTick Minimum price tick index + * @param maxTick Maximum price tick index + */ + function setRebalancePriceThreshold(int24 minTick, int24 maxTick) + external + onlyGovernorOrStrategist + { + require(minTick < maxTick, "Invalid threshold"); + minRebalanceTick = minTick; + maxRebalanceTick = maxTick; + emit RebalancePriceThresholdChanged(minTick, maxTick); + } + + /** + * @notice Change the swap price threshold + * @param minTick Minimum price tick index + * @param maxTick Maximum price tick index + */ + function setSwapPriceThreshold(int24 minTick, int24 maxTick) + external + onlyGovernorOrStrategist + { + require(minTick < maxTick, "Invalid threshold"); + minSwapPriceX96 = helper.getSqrtRatioAtTick(minTick); + maxSwapPriceX96 = helper.getSqrtRatioAtTick(maxTick); + emit SwapPriceThresholdChanged( + minTick, + minSwapPriceX96, + maxTick, + maxSwapPriceX96 + ); + } + + /*************************************** + Deposit/Withdraw + ****************************************/ + + /// @inheritdoc InitializableAbstractStrategy + function deposit(address, uint256) + external + override + onlyVault + nonReentrant + { + /** + * Uniswap V3 strategies are never meant to be default strategies. + * By design, they cannot hold any funds in the contract. When it needs + * funds to provide liquidity, it'll pull the required amounts from the + * reserve strategies. So, route any deposits to the reserve strategies + */ + revert("Direct deposits disabled on UniswapV3Strategy"); + } + + /// @inheritdoc InitializableAbstractStrategy + function depositAll() external override onlyVault nonReentrant { + /** + * Uniswap V3 strategies are never meant to be default strategies. + * By design, they cannot hold any funds in the contract. When it needs + * funds to provide liquidity, it'll pull the required amounts from the + * reserve strategies. So, route any deposits to the reserve strategies + */ + revert("Direct deposits disabled on UniswapV3Strategy"); + } + + /// @inheritdoc InitializableAbstractStrategy + function withdraw( + address recipient, + address _asset, + uint256 amount + ) external override onlyVault nonReentrant { + _onlyPoolTokens(_asset); + + require(activeTokenId == 0, "Active position still open"); + + // Transfer requested amount, will revert when low on balance + IERC20(_asset).safeTransfer(recipient, amount); + emit Withdrawal(_asset, _asset, amount); + } + + /** + * @notice Closes active LP position, if any, and transfer all token balance to Vault + * @inheritdoc InitializableAbstractStrategy + */ + function withdrawAll() external override onlyVault nonReentrant { + if (activeTokenId > 0) { + // Delegatecall to `UniswapV3LiquidityManager` to remove + // liquidity from active LP position + // solhint-disable-next-line no-unused-vars + (bool success, bytes memory data) = address(_self).delegatecall( + abi.encodeWithSelector( + IUniswapV3Strategy.closeActivePositionOnlyVault.selector + ) + ); + require(success, "DelegateCall to close position failed"); + } + + for (uint256 i = 0; i < 2; i++) { + IERC20 tokenContract = IERC20(assetsMapped[i]); + uint256 tokenBalance = tokenContract.balanceOf(address(this)); + + if (tokenBalance > 0) { + tokenContract.safeTransfer(vaultAddress, tokenBalance); + emit Withdrawal(assetsMapped[i], assetsMapped[i], tokenBalance); + } + } + } + + /*************************************** + Balances and Fees + ****************************************/ + /** + * @notice Returns the accumulated fees from the active position + * @return amount0 Amount of token0 ready to be collected as fee + * @return amount1 Amount of token1 ready to be collected as fee + */ + function getPendingFees() + external + view + returns (uint256 amount0, uint256 amount1) + { + if (activeTokenId > 0) { + require(tokenIdToPosition[activeTokenId].exists, "Invalid token"); + + (amount0, amount1) = helper.positionFees( + positionManager, + address(pool), + activeTokenId + ); + } + } + + /** + * @notice Only checks the active LP position and undeployed/undeposited balance held by the contract. + * Doesn't return the balance held in the reserve strategies. + * @inheritdoc InitializableAbstractStrategy + */ + function checkBalance(address _asset) + external + view + override + returns (uint256 balance) + { + _onlyPoolTokens(_asset); + balance = IERC20(_asset).balanceOf(address(this)); + + if (activeTokenId > 0) { + require(tokenIdToPosition[activeTokenId].exists, "Invalid token"); + (uint256 amount0, uint256 amount1) = getPositionBalance( + activeTokenId + ); + + if (_asset == token0) { + balance += amount0; + } else if (_asset == token1) { + balance += amount1; + } + } + } + + /** + * @dev Ensures that the asset address is either token0 or token1. + */ + function _onlyPoolTokens(address addr) internal view { + require(addr == token0 || addr == token1, "Unsupported asset"); + } + + /*************************************** + ERC721 management + ****************************************/ + + /// @notice Callback function for whenever a NFT is transferred to this contract + // solhint-disable-next-line max-line-length + /// Ref: https://docs.openzeppelin.com/contracts/3.x/api/token/erc721#IERC721Receiver-onERC721Received-address-address-uint256-bytes- + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external returns (bytes4) { + return this.onERC721Received.selector; + } + + /*************************************** + Inherited functions + ****************************************/ + + /// @inheritdoc InitializableAbstractStrategy + function safeApproveAllTokens() + external + override + onlyGovernor + nonReentrant + { + IERC20(token0).safeApprove(vaultAddress, type(uint256).max); + IERC20(token1).safeApprove(vaultAddress, type(uint256).max); + IERC20(token0).safeApprove(address(positionManager), type(uint256).max); + IERC20(token1).safeApprove(address(positionManager), type(uint256).max); + IERC20(token0).safeApprove(address(swapRouter), type(uint256).max); + IERC20(token1).safeApprove(address(swapRouter), type(uint256).max); + } + + /** + * @notice Removes all allowance of both the tokens from NonfungiblePositionManager as + * well as from the Uniswap V3 Swap Router + */ + function resetAllowanceOfTokens() external onlyGovernor nonReentrant { + IERC20(token0).safeApprove(address(positionManager), 0); + IERC20(token1).safeApprove(address(positionManager), 0); + IERC20(token0).safeApprove(address(swapRouter), 0); + IERC20(token1).safeApprove(address(swapRouter), 0); + } + + /// @inheritdoc InitializableAbstractStrategy + function _abstractSetPToken(address _asset, address) internal override { + IERC20(_asset).safeApprove(vaultAddress, type(uint256).max); + IERC20(_asset).safeApprove(address(positionManager), type(uint256).max); + IERC20(_asset).safeApprove(address(swapRouter), type(uint256).max); + } + + /// @inheritdoc InitializableAbstractStrategy + function supportsAsset(address _asset) + external + view + override + returns (bool) + { + return _asset == token0 || _asset == token1; + } + + /*************************************** + Hidden functions + ****************************************/ + /// @inheritdoc InitializableAbstractStrategy + function setPTokenAddress(address, address) external override { + // The pool tokens can never change. + revert("Unsupported method"); + } + + /// @inheritdoc InitializableAbstractStrategy + function removePToken(uint256) external override { + // The pool tokens can never change. + revert("Unsupported method"); + } + + /// @inheritdoc InitializableAbstractStrategy + function collectRewardTokens() external override { + // Do nothing + } + + /*************************************** + Proxy to liquidity management + ****************************************/ + /** + * @notice Sets the implementation for the liquidity manager + * @param newImpl address of the implementation + */ + function setLiquidityManagerImpl(address newImpl) external onlyGovernor { + require( + Address.isContract(newImpl), + "new implementation is not a contract" + ); + bytes32 position = LIQUIDITY_MANAGER_IMPL_POSITION; + // solhint-disable-next-line no-inline-assembly + assembly { + sstore(position, newImpl) + } + emit LiquidityManagerImplementationUpgraded(newImpl); + } + + /** + * @dev Falldown to the liquidity manager implementation + * @notice This is a catch all for all functions not declared here + */ + // solhint-disable-next-line no-complex-fallback + fallback() external { + bytes32 slot = LIQUIDITY_MANAGER_IMPL_POSITION; + // solhint-disable-next-line no-inline-assembly + assembly { + // Copy msg.data. We take full control of memory in this inline assembly + // block because it will not return to Solidity code. We overwrite the + // Solidity scratch pad at memory position 0. + calldatacopy(0, 0, calldatasize()) + + // Call the implementation. + // out and outsize are 0 because we don't know the size yet. + let result := delegatecall( + gas(), + sload(slot), + 0, + calldatasize(), + 0, + 0 + ) + + // Copy the returned data. + returndatacopy(0, 0, returndatasize()) + + switch result + // delegatecall returns 0 on error. + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } + } +} diff --git a/contracts/contracts/strategies/uniswap/UniswapV3StrategyStorage.sol b/contracts/contracts/strategies/uniswap/UniswapV3StrategyStorage.sol new file mode 100644 index 0000000000..aef4f194e7 --- /dev/null +++ b/contracts/contracts/strategies/uniswap/UniswapV3StrategyStorage.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +import { InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; +import { IStrategy } from "../../interfaces/IStrategy.sol"; +import { IVault } from "../../interfaces/IVault.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; +import { INonfungiblePositionManager } from "../../interfaces/uniswap/v3/INonfungiblePositionManager.sol"; +import { IUniswapV3Helper } from "../../interfaces/uniswap/v3/IUniswapV3Helper.sol"; +import { IUniswapV3Strategy } from "../../interfaces/IUniswapV3Strategy.sol"; +import { ISwapRouter } from "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; + +abstract contract UniswapV3StrategyStorage is InitializableAbstractStrategy { + event OperatorChanged(address indexed _address); + event LiquidityManagerImplementationUpgraded(address indexed _newImpl); + event ReserveStrategyChanged( + address indexed asset, + address reserveStrategy + ); + event MinDepositThresholdChanged( + address indexed asset, + uint256 minDepositThreshold + ); + event RebalancePauseStatusChanged(bool paused); + event SwapsPauseStatusChanged(bool paused); + event RebalancePriceThresholdChanged(int24 minTick, int24 maxTick); + event SwapPriceThresholdChanged( + int24 minTick, + uint160 minSwapPriceX96, + int24 maxTick, + uint160 maxSwapPriceX96 + ); + event MaxTVLChanged(uint256 maxTVL); + event MaxValueLostThresholdChanged(uint256 amount); + event NetLostValueReset(address indexed _by); + event NetLostValueChanged(uint256 currentNetLostValue); + event PositionValueChanged( + uint256 indexed tokenId, + uint256 initialValue, + uint256 currentValue, + int256 delta + ); + event AssetSwappedForRebalancing( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + uint256 amountOut + ); + event UniswapV3LiquidityAdded( + uint256 indexed tokenId, + uint256 amount0Sent, + uint256 amount1Sent, + uint128 liquidityMinted + ); + event UniswapV3LiquidityRemoved( + uint256 indexed tokenId, + uint256 amount0Received, + uint256 amount1Received, + uint128 liquidityBurned + ); + event UniswapV3PositionMinted( + uint256 indexed tokenId, + int24 lowerTick, + int24 upperTick + ); + event UniswapV3PositionClosed( + uint256 indexed tokenId, + uint256 amount0Received, + uint256 amount1Received + ); + event UniswapV3FeeCollected( + uint256 indexed tokenId, + uint256 amount0, + uint256 amount1 + ); + + // Represents a position minted by UniswapV3Strategy contract + struct Position { + uint256 tokenId; // ERC721 token Id of the minted position + uint128 liquidity; // Amount of liquidity deployed + int24 lowerTick; // Lower tick index + int24 upperTick; // Upper tick index + bool exists; // True, if position is minted + // The following two fields are redundant but since we use these + // two quite a lot, think it might be cheaper to store it than + // compute it every time? + uint160 sqrtRatioAX96; + uint160 sqrtRatioBX96; + uint256 netValue; // Last recorded net value of the position + } + + /// @notice The strategy's proxy contract address + /// @dev is set when initialized + IUniswapV3Strategy public _self; + + /// @notice The address that can manage the positions on Uniswap V3 + address public operatorAddr; + /// @notice Token0 of Uniswap V3 Pool + address public token0; + /// @notice Token1 of Uniswap V3 Pool + address public token1; + + // When the funds are not deployed in Uniswap V3 Pool, they will + // be deposited to these reserve strategies + /// @notice Reserve strategy for token0 + IStrategy public reserveStrategy0; + /// @notice Reserve strategy for token1 + IStrategy public reserveStrategy1; + + /// @notice Uniswap V3 Pool Fee + uint24 public poolFee; + /// @notice True if Swaps are paused + bool public swapsPaused = false; + /// @notice True if liquidity rebalances are paused + bool public rebalancePaused = false; + + /// @notice Maximum amount the strategy can have deployed in the Uniswap pool. In OTokens to 18 decimals + uint256 public maxTVL; + + // Deposits to reserve strategy when contract balance exceeds this amount + uint256 public minDepositThreshold0; + uint256 public minDepositThreshold1; + + // An upper and lower bound of rebalancing price limits + int24 public minRebalanceTick; + int24 public maxRebalanceTick; + + // An upper and lower bound of swap price limits + uint160 public minSwapPriceX96; + uint160 public maxSwapPriceX96; + + /// @notice Token ID of active Position on the pool. zero, if there are no active LP position + uint256 public activeTokenId; + + /// @notice Sum of loss in value of tokens deployed to the pool + uint256 public netLostValue; + + /// @notice Max value loss threshold after which rebalances aren't allowed + uint256 public maxPositionValueLostThreshold; + + /// @notice Uniswap V3's Pool + IUniswapV3Pool public pool; + + /// @notice Uniswap V3's PositionManager + INonfungiblePositionManager public positionManager; + + /// @notice A deployed contract that's used to call methods of Uniswap V3's libraries despite version mismatch + IUniswapV3Helper public helper; + + /// @notice Uniswap Swap Router + ISwapRouter public swapRouter; + + /// @notice A lookup table to find token IDs of position using f(lowerTick, upperTick) + mapping(int48 => uint256) public ticksToTokenId; + + /// @notice Maps tokenIDs to their Position object + mapping(uint256 => Position) public tokenIdToPosition; + + /// @notice keccak256("OUSD.UniswapV3Strategy.LiquidityManager.impl") + bytes32 constant LIQUIDITY_MANAGER_IMPL_POSITION = + 0xec676d52175f7cbb4e4ea392c6b70f8946575021aad20479602b98adc56ad62d; + + constructor() { + // Governor address is set on the proxy contract. There's no need to + // set a Governor for the implementation contract + _setGovernor(address(0)); + } + + /*************************************** + Modifiers + ****************************************/ + + /** + * @dev Ensures that the caller is Governor or Strategist. + */ + modifier onlyGovernorOrStrategist() { + require( + msg.sender == IVault(vaultAddress).strategistAddr() || + msg.sender == governor(), + "Caller is not the Strategist or Governor" + ); + _; + } + + /** + * @dev Ensures that the caller is Governor, Strategist or Operator. + */ + modifier onlyGovernorOrStrategistOrOperator() { + require( + msg.sender == operatorAddr || + msg.sender == IVault(vaultAddress).strategistAddr() || + msg.sender == governor(), + "Caller is not the Operator, Strategist or Governor" + ); + _; + } + + /*************************************** + Shared functions + ****************************************/ + /** + * @dev Deposits token balances in the contract back to the reserve strategies + */ + function _depositAll() internal { + uint256 token0Bal = IERC20(token0).balanceOf(address(this)); + uint256 token1Bal = IERC20(token1).balanceOf(address(this)); + IVault vault = IVault(vaultAddress); + + if (token0Bal > 0 && token0Bal >= minDepositThreshold0) { + vault.depositToUniswapV3Reserve(token0, token0Bal); + } + if (token1Bal > 0 && token1Bal >= minDepositThreshold1) { + vault.depositToUniswapV3Reserve(token1, token1Bal); + } + // Not emitting Deposit events since the Reserve strategies would do so + } + + /** + * @dev Returns the balance of both tokens in a given position (including fees) + * @param tokenId tokenID of the Position NFT + * @return amount0 Amount of token0 in position + * @return amount1 Amount of token1 in position + */ + function getPositionBalance(uint256 tokenId) + internal + view + returns (uint256 amount0, uint256 amount1) + { + require(tokenIdToPosition[tokenId].exists, "Invalid position"); + (uint160 sqrtRatioX96, , , , , , ) = pool.slot0(); + (amount0, amount1) = helper.positionTotal( + positionManager, + address(pool), + tokenId, + sqrtRatioX96 + ); + } + + /** + * @dev Returns the balance of both tokens in a given position (without fees) + * @param tokenId tokenID of the Position NFT + * @return amount0 Amount of token0 in position + * @return amount1 Amount of token1 in position + */ + function getPositionPrincipal(uint256 tokenId) + internal + view + returns (uint256 amount0, uint256 amount1) + { + require(tokenIdToPosition[tokenId].exists, "Invalid position"); + (uint160 sqrtRatioX96, , , , , , ) = pool.slot0(); + (amount0, amount1) = helper.positionPrincipal( + positionManager, + tokenId, + sqrtRatioX96 + ); + } +} diff --git a/contracts/contracts/utils/InitializableAbstractStrategy.sol b/contracts/contracts/utils/InitializableAbstractStrategy.sol index 26f7838df4..51e5d18a01 100644 --- a/contracts/contracts/utils/InitializableAbstractStrategy.sol +++ b/contracts/contracts/utils/InitializableAbstractStrategy.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.0; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { SafeMath } from "@openzeppelin/contracts/utils/math/SafeMath.sol"; import { Initializable } from "../utils/Initializable.sol"; import { Governable } from "../governance/Governable.sol"; @@ -11,7 +10,6 @@ import { IVault } from "../interfaces/IVault.sol"; abstract contract InitializableAbstractStrategy is Initializable, Governable { using SafeERC20 for IERC20; - using SafeMath for uint256; event PTokenAdded(address indexed _asset, address _pToken); event PTokenRemoved(address indexed _asset, address _pToken); @@ -89,7 +87,7 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { function _initialize( address _platformAddress, address _vaultAddress, - address[] calldata _rewardTokenAddresses, + address[] memory _rewardTokenAddresses, address[] memory _assets, address[] memory _pTokens ) internal { @@ -105,7 +103,7 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { } /** - * @dev Collect accumulated reward token and send to Vault. + * @notice Collect accumulated reward token and send to Harvester. */ function collectRewardTokens() external virtual onlyHarvester nonReentrant { _collectRewardTokens(); @@ -165,7 +163,7 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { } /** - * @dev Set the reward token addresses. + * @notice Set the reward token addresses. * @param _rewardTokenAddresses Address array of the reward token */ function setRewardTokenAddresses(address[] calldata _rewardTokenAddresses) @@ -187,7 +185,7 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { } /** - * @dev Get the reward token addresses. + * @notice Get the reward token addresses. * @return address[] the reward token addresses. */ function getRewardTokenAddresses() @@ -199,24 +197,25 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { } /** - * @dev Provide support for asset by passing its pToken address. + * @notice Provide support for asset by passing its pToken address. * This method can only be called by the system Governor * @param _asset Address for the asset * @param _pToken Address for the corresponding platform token */ function setPTokenAddress(address _asset, address _pToken) external + virtual onlyGovernor { _setPTokenAddress(_asset, _pToken); } /** - * @dev Remove a supported asset by passing its index. + * @notice Remove a supported asset by passing its index. * This method can only be called by the system Governor * @param _assetIndex Index of the asset to be removed */ - function removePToken(uint256 _assetIndex) external onlyGovernor { + function removePToken(uint256 _assetIndex) external virtual onlyGovernor { require(_assetIndex < assetsMapped.length, "Invalid index"); address asset = assetsMapped[_assetIndex]; address pToken = assetToPToken[asset]; @@ -253,7 +252,7 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { } /** - * @dev Transfer token to governor. Intended for recovering tokens stuck in + * @notice Transfer token to governor. Intended for recovering tokens stuck in * strategy contracts, i.e. mistaken sends. * @param _asset Address for the asset * @param _amount Amount of the asset to transfer @@ -266,7 +265,7 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { } /** - * @dev Set the reward token addresses. + * @notice Set the reward token addresses. * @param _harvesterAddress Address of the harvester */ function setHarvesterAddress(address _harvesterAddress) @@ -281,26 +280,36 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { Abstract ****************************************/ + /** + * @dev Callback function that's invoked everytime + * PToken address of an supported asset is updated. + * @param _asset Address for the asset + * @param _pToken Adress of the platform token + */ function _abstractSetPToken(address _asset, address _pToken) internal virtual; + /** + * @notice Approve all the assets supported by the strategy + * to be moved around the platform. + */ function safeApproveAllTokens() external virtual; /** - * @dev Deposit an amount of asset into the platform + * @notice Deposit an amount of asset into the platform * @param _asset Address for the asset * @param _amount Units of asset to deposit */ function deposit(address _asset, uint256 _amount) external virtual; /** - * @dev Deposit balance of all supported assets into the platform + * @notice Deposit balance of all supported assets into the platform */ function depositAll() external virtual; /** - * @dev Withdraw an amount of asset from the platform. + * @notice Withdraw an amount of asset from the platform. * @param _recipient Address to which the asset should be sent * @param _asset Address of the asset * @param _amount Units of asset to withdraw @@ -312,12 +321,12 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { ) external virtual; /** - * @dev Withdraw all assets from strategy sending assets to Vault. + * @notice Withdraw all assets from strategy sending assets to Vault. */ function withdrawAll() external virtual; /** - * @dev Get the total asset value held in the platform. + * @notice Get the total asset value held in the platform. * This includes any interest that was generated since depositing. * @param _asset Address of the asset * @return balance Total value of the asset in the platform @@ -329,7 +338,7 @@ abstract contract InitializableAbstractStrategy is Initializable, Governable { returns (uint256 balance); /** - * @dev Check if an asset is supported. + * @notice Check if an asset is supported. * @param _asset Address of the asset * @return bool Whether asset is supported */ diff --git a/contracts/contracts/utils/UniswapV3Helper.sol b/contracts/contracts/utils/UniswapV3Helper.sol new file mode 100644 index 0000000000..5bad37c35c --- /dev/null +++ b/contracts/contracts/utils/UniswapV3Helper.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity =0.7.6; + +import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; +import "@uniswap/v3-core/contracts/libraries/FixedPoint128.sol"; +import "@uniswap/v3-core/contracts/libraries/TickMath.sol"; +import "@uniswap/v3-periphery/contracts/libraries/LiquidityAmounts.sol"; +import { INonfungiblePositionManager } from "../interfaces/uniswap/v3/INonfungiblePositionManager.sol"; + +/** + * @dev Uniswap V3 Contracts use Solidity v0.7.6 and OUSD contracts are on 0.8.7. + * So, the libraries cannot be directly imported into OUSD contracts. + * This contract (on v0.7.6) just proxies the calls to the Uniswap Libraries. + */ +contract UniswapV3Helper { + function getAmountsForLiquidity( + uint160 sqrtRatioX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity + ) external pure returns (uint256 amount0, uint256 amount1) { + return + LiquidityAmounts.getAmountsForLiquidity( + sqrtRatioX96, + sqrtRatioAX96, + sqrtRatioBX96, + liquidity + ); + } + + function getLiquidityForAmounts( + uint160 sqrtRatioX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint256 amount0, + uint256 amount1 + ) external pure returns (uint128 liquidity) { + return + LiquidityAmounts.getLiquidityForAmounts( + sqrtRatioX96, + sqrtRatioAX96, + sqrtRatioBX96, + amount0, + amount1 + ); + } + + function getSqrtRatioAtTick(int24 tick) + external + pure + returns (uint160 sqrtPriceX96) + { + return TickMath.getSqrtRatioAtTick(tick); + } + + function positionFees( + INonfungiblePositionManager positionManager, + address poolAddress, + uint256 tokenId + ) external view returns (uint256 amount0, uint256 amount1) { + return PositionValue.fees(positionManager, poolAddress, tokenId); + } + + function positionTotal( + INonfungiblePositionManager positionManager, + address poolAddress, + uint256 tokenId, + uint160 sqrtRatioX96 + ) external view returns (uint256 amount0, uint256 amount1) { + return + PositionValue.total( + positionManager, + poolAddress, + tokenId, + sqrtRatioX96 + ); + } + + function positionPrincipal( + INonfungiblePositionManager positionManager, + uint256 tokenId, + uint160 sqrtRatioX96 + ) external view returns (uint256 amount0, uint256 amount1) { + return PositionValue.principal(positionManager, tokenId, sqrtRatioX96); + } +} + +/// @dev Couldn't import this library directly either because of issues with OpenZeppelin versioning +/// @title Returns information about the token value held in a Uniswap V3 NFT +library PositionValue { + /// @notice Returns the total amounts of token0 and token1, i.e. the sum of fees and principal + /// that a given nonfungible position manager token is worth + /// @param positionManager The Uniswap V3 NonfungiblePositionManager + /// @param poolAddress The Uniswap V3 Pool + /// @param tokenId The tokenId of the token for which to get the total value + /// @param sqrtRatioX96 The square root price X96 for which to calculate the principal amounts + /// @return amount0 The total amount of token0 including principal and fees + /// @return amount1 The total amount of token1 including principal and fees + function total( + INonfungiblePositionManager positionManager, + address poolAddress, + uint256 tokenId, + uint160 sqrtRatioX96 + ) internal view returns (uint256 amount0, uint256 amount1) { + (uint256 amount0Principal, uint256 amount1Principal) = principal( + positionManager, + tokenId, + sqrtRatioX96 + ); + (uint256 amount0Fee, uint256 amount1Fee) = fees( + positionManager, + poolAddress, + tokenId + ); + return (amount0Principal + amount0Fee, amount1Principal + amount1Fee); + } + + /// @notice Calculates the principal (currently acting as liquidity) owed to the token owner in the event + /// that the position is burned + /// @param positionManager The Uniswap V3 NonfungiblePositionManager + /// @param tokenId The tokenId of the token for which to get the total principal owed + /// @param sqrtRatioX96 The square root price X96 for which to calculate the principal amounts + /// @return amount0 The principal amount of token0 + /// @return amount1 The principal amount of token1 + function principal( + INonfungiblePositionManager positionManager, + uint256 tokenId, + uint160 sqrtRatioX96 + ) internal view returns (uint256 amount0, uint256 amount1) { + ( + , + , + , + , + , + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + , + , + , + + ) = positionManager.positions(tokenId); + + return + LiquidityAmounts.getAmountsForLiquidity( + sqrtRatioX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidity + ); + } + + struct FeeParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint128 liquidity; + uint256 positionFeeGrowthInside0LastX128; + uint256 positionFeeGrowthInside1LastX128; + uint256 tokensOwed0; + uint256 tokensOwed1; + } + + /// @notice Calculates the total fees owed to the token owner + /// @param positionManager The Uniswap V3 NonfungiblePositionManager + /// @param poolAddress The Uniswap V3 Pool + /// @param tokenId The tokenId of the token for which to get the total fees owed + /// @return amount0 The amount of fees owed in token0 + /// @return amount1 The amount of fees owed in token1 + function fees( + INonfungiblePositionManager positionManager, + address poolAddress, + uint256 tokenId + ) internal view returns (uint256 amount0, uint256 amount1) { + ( + , + , + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 positionFeeGrowthInside0LastX128, + uint256 positionFeeGrowthInside1LastX128, + uint256 tokensOwed0, + uint256 tokensOwed1 + ) = positionManager.positions(tokenId); + + return + _fees( + poolAddress, + FeeParams({ + token0: token0, + token1: token1, + fee: fee, + tickLower: tickLower, + tickUpper: tickUpper, + liquidity: liquidity, + positionFeeGrowthInside0LastX128: positionFeeGrowthInside0LastX128, + positionFeeGrowthInside1LastX128: positionFeeGrowthInside1LastX128, + tokensOwed0: tokensOwed0, + tokensOwed1: tokensOwed1 + }) + ); + } + + function _fees(address poolAddress, FeeParams memory feeParams) + private + view + returns (uint256 amount0, uint256 amount1) + { + ( + uint256 poolFeeGrowthInside0LastX128, + uint256 poolFeeGrowthInside1LastX128 + ) = _getFeeGrowthInside( + IUniswapV3Pool(poolAddress), + feeParams.tickLower, + feeParams.tickUpper + ); + + amount0 = + FullMath.mulDiv( + poolFeeGrowthInside0LastX128 - + feeParams.positionFeeGrowthInside0LastX128, + feeParams.liquidity, + FixedPoint128.Q128 + ) + + feeParams.tokensOwed0; + + amount1 = + FullMath.mulDiv( + poolFeeGrowthInside1LastX128 - + feeParams.positionFeeGrowthInside1LastX128, + feeParams.liquidity, + FixedPoint128.Q128 + ) + + feeParams.tokensOwed1; + } + + function _getFeeGrowthInside( + IUniswapV3Pool pool, + int24 tickLower, + int24 tickUpper + ) + private + view + returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) + { + (, int24 tickCurrent, , , , , ) = pool.slot0(); + ( + , + , + uint256 lowerFeeGrowthOutside0X128, + uint256 lowerFeeGrowthOutside1X128, + , + , + , + + ) = pool.ticks(tickLower); + ( + , + , + uint256 upperFeeGrowthOutside0X128, + uint256 upperFeeGrowthOutside1X128, + , + , + , + + ) = pool.ticks(tickUpper); + + if (tickCurrent < tickLower) { + feeGrowthInside0X128 = + lowerFeeGrowthOutside0X128 - + upperFeeGrowthOutside0X128; + feeGrowthInside1X128 = + lowerFeeGrowthOutside1X128 - + upperFeeGrowthOutside1X128; + } else if (tickCurrent < tickUpper) { + uint256 feeGrowthGlobal0X128 = pool.feeGrowthGlobal0X128(); + uint256 feeGrowthGlobal1X128 = pool.feeGrowthGlobal1X128(); + feeGrowthInside0X128 = + feeGrowthGlobal0X128 - + lowerFeeGrowthOutside0X128 - + upperFeeGrowthOutside0X128; + feeGrowthInside1X128 = + feeGrowthGlobal1X128 - + lowerFeeGrowthOutside1X128 - + upperFeeGrowthOutside1X128; + } else { + feeGrowthInside0X128 = + upperFeeGrowthOutside0X128 - + lowerFeeGrowthOutside0X128; + feeGrowthInside1X128 = + upperFeeGrowthOutside1X128 - + lowerFeeGrowthOutside1X128; + } + } +} diff --git a/contracts/contracts/vault/VaultAdmin.sol b/contracts/contracts/vault/VaultAdmin.sol index c44e8061b6..6c2b0f094f 100644 --- a/contracts/contracts/vault/VaultAdmin.sol +++ b/contracts/contracts/vault/VaultAdmin.sol @@ -11,6 +11,7 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { StableMath } from "../utils/StableMath.sol"; import { IOracle } from "../interfaces/IOracle.sol"; +import { IUniswapV3Strategy } from "../interfaces/IUniswapV3Strategy.sol"; import "./VaultStorage.sol"; contract VaultAdmin is VaultStorage { @@ -41,6 +42,15 @@ contract VaultAdmin is VaultStorage { _; } + modifier onlyUniswapV3Strategies() { + Strategy memory strategy = strategies[msg.sender]; + require( + strategy.isSupported && strategy.isUniswapV3Strategy, + "Caller is not Uniswap V3 Strategy" + ); + _; + } + /*************************************** Configuration ****************************************/ @@ -126,6 +136,10 @@ contract VaultAdmin is VaultStorage { if (_strategy != address(0)) { // Make sure the strategy meets some criteria require(strategies[_strategy].isSupported, "Strategy not approved"); + require( + !strategies[_strategy].isUniswapV3Strategy, + "UniswapV3Strategy not supported" + ); IStrategy strategy = IStrategy(_strategy); require(assets[_asset].isSupported, "Asset is not supported"); require( @@ -205,8 +219,29 @@ contract VaultAdmin is VaultStorage { * @param _addr Address of the strategy to add */ function approveStrategy(address _addr) external onlyGovernor { + _approveStrategy(_addr, false); + } + + /** + * @dev Add a strategy to the Vault and mark it as Uniswap V3 strategy + * @param _addr Address of the strategy to add + */ + function approveUniswapV3Strategy(address _addr) external onlyGovernor { + _approveStrategy(_addr, true); + } + + /** + * @dev Add a strategy to the Vault + * @param _addr Address of the strategy to add + * @param isUniswapV3 Set to true, if the strategy is an instance of UniswapV3Strategy + */ + function _approveStrategy(address _addr, bool isUniswapV3) internal { require(!strategies[_addr].isSupported, "Strategy already approved"); - strategies[_addr] = Strategy({ isSupported: true, _deprecated: 0 }); + strategies[_addr] = Strategy({ + isSupported: true, + _deprecated: 0, + isUniswapV3Strategy: isUniswapV3 + }); allStrategies.push(_addr); emit StrategyApproved(_addr); } @@ -215,7 +250,6 @@ contract VaultAdmin is VaultStorage { * @notice Remove a strategy from the Vault. * @param _addr Address of the strategy to remove */ - function removeStrategy(address _addr) external onlyGovernor { require(strategies[_addr].isSupported, "Strategy not approved"); @@ -253,6 +287,52 @@ contract VaultAdmin is VaultStorage { } } + /** + * @notice Checks if the address is a supported strategy + * @param _addr Address of the strategy to check + * @return supported True, if strategy is recognized by the vault + */ + function isStrategySupported(address _addr) + external + view + returns (bool supported) + { + supported = strategies[_addr].isSupported; + } + + /** + * @dev Move assets from one Strategy to another + * @param _strategyFromAddress Address of Strategy to move assets from. + * @param _strategyToAddress Address of Strategy to move assets to. + * @param _assets Array of asset address that will be moved + * @param _amounts Array of amounts of each corresponding asset to move. + */ + function reallocate( + address _strategyFromAddress, + address _strategyToAddress, + address[] calldata _assets, + uint256[] calldata _amounts + ) external onlyGovernorOrStrategist { + require( + strategies[_strategyToAddress].isSupported, + "Invalid to Strategy" + ); + require(_assets.length == _amounts.length, "Parameter length mismatch"); + _withdrawFromStrategy( + _strategyToAddress, + _strategyFromAddress, + _assets, + _amounts + ); + + IStrategy strategyTo = IStrategy(_strategyToAddress); + for (uint256 i = 0; i < _assets.length; i++) { + require(strategyTo.supportsAsset(_assets[i]), "Asset unsupported"); + } + // Tell new Strategy to deposit into protocol + strategyTo.depositAll(); + } + /** * @notice Deposit multiple assets from the vault into the strategy. * @param _strategyToAddress Address of the Strategy to deposit assets into. @@ -462,6 +542,74 @@ contract VaultAdmin is VaultStorage { } } + /*************************************** + Uniswap V3 Utils + ****************************************/ + + /** + * @dev Deposits token to the reserve strategy + * @dev Only callable by whitelisted Uniswap V3 strategies + * @param asset The asset to deposit + * @param amount Amount of tokens to deposit + */ + function depositToUniswapV3Reserve(address asset, uint256 amount) + external + onlyUniswapV3Strategies + nonReentrant + { + _depositToUniswapV3Reserve(msg.sender, asset, amount); + } + + /** + * @dev Deposits token to the reserve strategy + * @param v3Strategy Uniswap V3 Strategy that's depositing the tokens + * @param asset The asset to deposit + * @param amount Amount of tokens to deposit + */ + function _depositToUniswapV3Reserve( + address v3Strategy, + address asset, + uint256 amount + ) internal { + require(strategies[v3Strategy].isSupported, "Strategy not approved"); + address reserveStrategy = IUniswapV3Strategy(v3Strategy) + .reserveStrategy(asset); + require( + strategies[reserveStrategy].isSupported, + "Unknown reserve strategy" + ); + IERC20(asset).safeTransferFrom(v3Strategy, reserveStrategy, amount); + IStrategy(reserveStrategy).deposit(asset, amount); + } + + /** + * @notice Moves tokens from reserve strategy to the Uniswap V3 Strategy + * @dev Only callable by whitelisted Uniswap V3 strategies + * @param asset Address of the token + * @param amount Amount of token1 required + */ + function withdrawFromUniswapV3Reserve(address asset, uint256 amount) + external + onlyUniswapV3Strategies + nonReentrant + { + IUniswapV3Strategy v3Strategy = IUniswapV3Strategy(msg.sender); + require(amount > 0, "Invalid amount specified"); + + address reserveStrategyAddr = IUniswapV3Strategy(v3Strategy) + .reserveStrategy(asset); + require( + strategies[reserveStrategyAddr].isSupported, + "Unknown reserve strategy" + ); + + IStrategy reserveStrategy = IStrategy(reserveStrategyAddr); + require(reserveStrategy.supportsAsset(asset), "Unsupported asset"); + reserveStrategy.withdraw(msg.sender, asset, amount); + + emit AssetTransferredToUniswapV3Strategy(msg.sender, asset, amount); + } + /*************************************** Utils ****************************************/ diff --git a/contracts/contracts/vault/VaultCore.sol b/contracts/contracts/vault/VaultCore.sol index e6731e76b8..48bfc7eb59 100644 --- a/contracts/contracts/vault/VaultCore.sol +++ b/contracts/contracts/vault/VaultCore.sol @@ -319,7 +319,8 @@ contract VaultCore is VaultStorage { // strategy uint256 assetCount = allAssets.length; for (uint256 i = 0; i < assetCount; ++i) { - IERC20 asset = IERC20(allAssets[i]); + address assetAddr = allAssets[i]; + IERC20 asset = IERC20(assetAddr); uint256 assetBalance = asset.balanceOf(address(this)); // No balance, nothing to do here if (assetBalance == 0) continue; @@ -330,18 +331,17 @@ contract VaultCore is VaultStorage { vaultBufferModifier ); - address depositStrategyAddr = assetDefaultStrategies[ - address(asset) - ]; + address depositStrategyAddr = assetDefaultStrategies[assetAddr]; if (depositStrategyAddr != address(0) && allocateAmount > 0) { IStrategy strategy = IStrategy(depositStrategyAddr); + // Transfer asset to Strategy and call deposit method to // mint or take required action asset.safeTransfer(address(strategy), allocateAmount); - strategy.deposit(address(asset), allocateAmount); + strategy.deposit(assetAddr, allocateAmount); emit AssetAllocated( - address(asset), + assetAddr, depositStrategyAddr, allocateAmount ); @@ -416,7 +416,7 @@ contract VaultCore is VaultStorage { /** * @dev Internal to calculate total value of all assets held in Vault. - * @return value Total value in ETH (1e18) + * @return value Total value in USD (1e18) */ function _totalValueInVault() internal view returns (uint256 value) { uint256 assetCount = allAssets.length; @@ -431,7 +431,7 @@ contract VaultCore is VaultStorage { /** * @dev Internal to calculate total value of all assets held in Strategies. - * @return value Total value in ETH (1e18) + * @return value Total value in USD (1e18) */ function _totalValueInStrategies() internal view returns (uint256 value) { uint256 stratCount = allStrategies.length; @@ -443,7 +443,7 @@ contract VaultCore is VaultStorage { /** * @dev Internal to calculate total value of all assets held by strategy. * @param _strategyAddr Address of the strategy - * @return value Total value in ETH (1e18) + * @return value Total value in USD (1e18) */ function _totalValueInStrategy(address _strategyAddr) internal @@ -779,7 +779,7 @@ contract VaultCore is VaultStorage { */ // solhint-disable-next-line no-complex-fallback fallback() external payable { - bytes32 slot = adminImplPosition; + bytes32 slot = ADMIN_IMPL_POSITION; // solhint-disable-next-line no-inline-assembly assembly { // Copy msg.data. We take full control of memory in this inline assembly diff --git a/contracts/contracts/vault/VaultStorage.sol b/contracts/contracts/vault/VaultStorage.sol index 859b99cf31..e4f6b99990 100644 --- a/contracts/contracts/vault/VaultStorage.sol +++ b/contracts/contracts/vault/VaultStorage.sol @@ -13,6 +13,7 @@ import { SafeMath } from "@openzeppelin/contracts/utils/math/SafeMath.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { IStrategy } from "../interfaces/IStrategy.sol"; +import { IUniswapV3Strategy } from "../interfaces/IUniswapV3Strategy.sol"; import { Governable } from "../governance/Governable.sol"; import { OUSD } from "../token/OUSD.sol"; import { Initializable } from "../utils/Initializable.sol"; @@ -48,6 +49,11 @@ contract VaultStorage is Initializable, Governable { event TrusteeFeeBpsChanged(uint256 _basis); event TrusteeAddressChanged(address _address); event NetOusdMintForStrategyThresholdChanged(uint256 _threshold); + event AssetTransferredToUniswapV3Strategy( + address indexed strategy, + address indexed asset, + uint256 amount + ); // Assets supported by the Vault, i.e. Stablecoins enum UnitConversion { @@ -70,6 +76,8 @@ contract VaultStorage is Initializable, Governable { struct Strategy { bool isSupported; uint256 _deprecated; // Deprecated storage slot + // Set to true if the Strategy is an instance of `UniswapV3Strategy` + bool isUniswapV3Strategy; } /// @dev mapping of strategy contracts to their configiration mapping(address => Strategy) internal strategies; @@ -97,7 +105,7 @@ contract VaultStorage is Initializable, Governable { OUSD internal oUSD; //keccak256("OUSD.vault.governor.admin.impl"); - bytes32 constant adminImplPosition = + bytes32 constant ADMIN_IMPL_POSITION = 0xa2bd3d3cf188a41358c8b401076eb59066b09dec5775650c0de4c55187d17bd9; // Address of the contract responsible for post rebase syncs with AMMs @@ -149,7 +157,7 @@ contract VaultStorage is Initializable, Governable { Address.isContract(newImpl), "new implementation is not a contract" ); - bytes32 position = adminImplPosition; + bytes32 position = ADMIN_IMPL_POSITION; // solhint-disable-next-line no-inline-assembly assembly { sstore(position, newImpl) diff --git a/contracts/deploy/000_mock.js b/contracts/deploy/000_mock.js index 373ae79553..d293b307b5 100644 --- a/contracts/deploy/000_mock.js +++ b/contracts/deploy/000_mock.js @@ -26,6 +26,7 @@ const { abi: QUOTER_ABI, bytecode: QUOTER_BYTECODE, } = require("@uniswap/v3-periphery/artifacts/contracts/lens/Quoter.sol/Quoter.json"); +const { ethers } = require("hardhat"); const deployMocks = async ({ getNamedAccounts, deployments }) => { const { deploy } = deployments; @@ -355,6 +356,10 @@ const deployMocks = async ({ getNamedAccounts, deployments }) => { args: [factory.address, weth.address], }); + // This is completely unrelated to the Uniswap V3 mocks above. + // This one focuses only on Uniswap V3 strategies + await deployMocksForUniswapV3Strategy(deploy, deployerAddr); + await deploy("MockFrxETHMinter", { from: deployerAddr, args: [(await ethers.getContract("MocksfrxETH")).address], @@ -365,6 +370,26 @@ const deployMocks = async ({ getNamedAccounts, deployments }) => { return true; }; +async function deployMocksForUniswapV3Strategy(deploy, deployerAddr) { + const v3Helper = await deploy("UniswapV3Helper", { + from: deployerAddr, + contract: "UniswapV3Helper", + }); + + const mockUSDT = await ethers.getContract("MockUSDT"); + const mockUSDC = await ethers.getContract("MockUSDC"); + + const mockPool = await deploy("MockUniswapV3Pool", { + from: deployerAddr, + args: [mockUSDC.address, mockUSDT.address, 500, v3Helper.address], + }); + + await deploy("MockNonfungiblePositionManager", { + from: deployerAddr, + args: [v3Helper.address, mockPool.address], + }); +} + deployMocks.id = "000_mock"; deployMocks.tags = ["mocks", "unit_tests"]; deployMocks.skip = () => isMainnetOrFork; diff --git a/contracts/deploy/001_core.js b/contracts/deploy/001_core.js index 9d6517d382..778cfbe47c 100644 --- a/contracts/deploy/001_core.js +++ b/contracts/deploy/001_core.js @@ -710,6 +710,15 @@ const configureStrategies = async (harvesterProxy, oethHarvesterProxy) => { threePool.connect(sGovernor).setHarvesterAddress(harvesterProxy.address) ); + const uniV3UsdcUsdtProxy = await ethers.getContract("UniV3_USDC_USDT_Proxy"); + const uniV3UsdcUsdt = await ethers.getContractAt( + "UniswapV3Strategy", + uniV3UsdcUsdtProxy.address + ); + await withConfirmation( + uniV3UsdcUsdt.connect(sGovernor).setHarvesterAddress(harvesterProxy.address) + ); + // OETH Strategies const fraxEthStrategyProxy = await ethers.getContract("FraxETHStrategyProxy"); const fraxEthStrategy = await ethers.getContractAt( @@ -1189,6 +1198,107 @@ const deployWOusd = async () => { await wousd.connect(sGovernor).claimGovernance(); }; +const deployUniswapV3Strategy = async () => { + const { deployerAddr, governorAddr, operatorAddr } = await getNamedAccounts(); + + const sDeployer = await ethers.provider.getSigner(deployerAddr); + const sGovernor = await ethers.provider.getSigner(governorAddr); + + const vault = await ethers.getContract("VaultProxy"); + const pool = await ethers.getContract("MockUniswapV3Pool"); + const manager = await ethers.getContract("MockNonfungiblePositionManager"); + const v3Helper = await ethers.getContract("UniswapV3Helper"); + + const mockUSDT = await ethers.getContract("MockUSDT"); + const mockUSDC = await ethers.getContract("MockUSDC"); + const mockDAI = await ethers.getContract("MockDAI"); + const mockRouter = await ethers.getContract("MockUniswapRouter"); + + await deployWithConfirmation("MockStrategy", [ + vault.address, + [mockUSDC.address, mockUSDT.address], + ]); + + await deployWithConfirmation( + "MockStrategy2", + [vault.address, [mockUSDC.address, mockUSDT.address]], + "MockStrategy" + ); + + await deployWithConfirmation( + "MockStrategyDAI", + [vault.address, [mockDAI.address]], + "MockStrategy" + ); + + // const mockStrat = await ethers.getContract("MockStrategy"); + + const dUniswapV3Strategy = await deployWithConfirmation("UniswapV3Strategy"); + const dUniswapV3LiquidityManager = await deployWithConfirmation( + "UniswapV3LiquidityManager" + ); + + await deployWithConfirmation("UniV3_USDC_USDT_Proxy"); + const uniV3Proxy = await ethers.getContract("UniV3_USDC_USDT_Proxy"); + + await withConfirmation( + uniV3Proxy["initialize(address,address,bytes)"]( + dUniswapV3Strategy.address, + deployerAddr, + [] + ) + ); + log("Initialized UniV3_USDC_USDT_Proxy"); + + const uniV3Strat = await ethers.getContractAt( + "UniswapV3Strategy", + uniV3Proxy.address + ); + await withConfirmation( + uniV3Strat + .connect(sDeployer) + [ + "initialize(address,address,address,address,address,address,uint256,uint256)" + ]( + vault.address, + pool.address, + manager.address, + v3Helper.address, + mockRouter.address, + operatorAddr, + "1000000000000", + "50000000000" + ) + ); + log("Initialized UniswapV3Strategy"); + + await withConfirmation( + uniV3Strat + .connect(sDeployer) + .setLiquidityManagerImpl(dUniswapV3LiquidityManager.address) + ); + log("Initialized UniswapV3LiquidityManager"); + + await withConfirmation( + uniV3Strat.connect(sDeployer).transferGovernance(governorAddr) + ); + log(`UniswapV3Strategy transferGovernance(${governorAddr}) called`); + + // On Mainnet the governance transfer gets executed separately, via the + // multi-sig wallet. On other networks, this migration script can claim + // governance by the governor. + if (!isMainnet) { + await withConfirmation( + uniV3Strat + .connect(sGovernor) // Claim governance with governor + .claimGovernance() + ); + log("Claimed governance for UniV3_USDC_USDT_Strategy"); + } + + return uniV3Strat; +}; + const main = async () => { console.log("Running 001_core deployment..."); await deployOracles(); @@ -1201,6 +1311,7 @@ const main = async () => { await deployConvexStrategy(); await deployConvexOUSDMetaStrategy(); await deployConvexLUSDMetaStrategy(); + await deployUniswapV3Strategy(); await deployFraxEthStrategy(); const [harvesterProxy, oethHarvesterProxy] = await deployHarvesters(); await configureVault(); @@ -1218,7 +1329,7 @@ const main = async () => { main.id = "001_core"; main.dependencies = ["mocks"]; -main.tags = ["unit_tests"]; +main.tags = ["core", "unit_tests"]; main.skip = () => isFork; module.exports = main; diff --git a/contracts/deploy/004_single_asset_staking.js b/contracts/deploy/004_single_asset_staking.js index 4fe79afda2..44cb23a379 100644 --- a/contracts/deploy/004_single_asset_staking.js +++ b/contracts/deploy/004_single_asset_staking.js @@ -191,7 +191,7 @@ const singleAssetStaking = async ({ getNamedAccounts, deployments }) => { singleAssetStaking.id = deployName; singleAssetStaking.dependencies = ["core"]; - +singleAssetStaking.tags = ["unit_tests"]; /** * The contract is no longer in use and isn't expected to be updated */ diff --git a/contracts/deploy/005_compensation_claims.js b/contracts/deploy/005_compensation_claims.js index 169f3317c5..edfea63f21 100644 --- a/contracts/deploy/005_compensation_claims.js +++ b/contracts/deploy/005_compensation_claims.js @@ -77,7 +77,7 @@ const compensationClaimsDeploy = async ({ getNamedAccounts }) => { compensationClaimsDeploy.id = deployName; compensationClaimsDeploy.dependencies = ["core"]; - +compensationClaimsDeploy.tags = ["unit_tests"]; /** * The contract is no longer in use and isn't expected to be updated */ diff --git a/contracts/deploy/070_uniswap_usdc_usdt_strategy.js b/contracts/deploy/070_uniswap_usdc_usdt_strategy.js new file mode 100644 index 0000000000..5d924cbdd4 --- /dev/null +++ b/contracts/deploy/070_uniswap_usdc_usdt_strategy.js @@ -0,0 +1,195 @@ +const { deploymentWithGovernanceProposal } = require("../utils/deploy"); +const { utils } = require("ethers"); + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "070_uniswap_usdc_usdt_strategy", + forceDeploy: false, + }, + async ({ + assetAddresses, + deployWithConfirmation, + ethers, + getTxOpts, + withConfirmation, + }) => { + const { deployerAddr, operatorAddr, timelockAddr } = + await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + // Current contracts + const cVaultProxy = await ethers.getContract("VaultProxy"); + const cVault = await ethers.getContractAt("Vault", cVaultProxy.address); + const cVaultAdmin = await ethers.getContractAt( + "VaultAdmin", + cVaultProxy.address + ); + + // Deployer Actions + // ---------------- + + // 0. Deploy UniswapV3Helper + const dUniswapV3Helper = await deployWithConfirmation("UniswapV3Helper"); + + // 0. Upgrade VaultAdmin + const dVaultAdmin = await deployWithConfirmation("VaultAdmin"); + const dVaultCore = await deployWithConfirmation("VaultCore"); + + // 1. Deploy new proxy + // New strategy will be living at a clean address + const dUniV3_USDC_USDT_Proxy = await deployWithConfirmation( + "UniV3_USDC_USDT_Proxy" + ); + const cUniV3_USDC_USDT_Proxy = await ethers.getContractAt( + "UniV3_USDC_USDT_Proxy", + dUniV3_USDC_USDT_Proxy.address + ); + + // 2. Deploy new implementation + const dUniV3_USDC_USDT_StrategyImpl = await deployWithConfirmation( + "UniswapV3Strategy" + ); + const dUniV3PoolLiquidityManager = await deployWithConfirmation( + "UniswapV3LiquidityManager" + ); + const cUniV3_USDC_USDT_Strategy = await ethers.getContractAt( + "UniswapV3Strategy", + dUniV3_USDC_USDT_Proxy.address + ); + + const cAaveProxy = await ethers.getContract("AaveStrategyProxy"); + + const cHarvesterProxy = await ethers.getContract("HarvesterProxy"); + const cHarvester = await ethers.getContractAt( + "Harvester", + cHarvesterProxy.address + ); + + // 3. Init the proxy to point at the implementation + await withConfirmation( + cUniV3_USDC_USDT_Proxy + .connect(sDeployer) + ["initialize(address,address,bytes)"]( + dUniV3_USDC_USDT_StrategyImpl.address, + deployerAddr, + [], + await getTxOpts() + ) + ); + + // 4. Init and configure new Uniswap V3 strategy + const initFunction = + "initialize(address,address,address,address,address,address,uint256,uint256)"; + await withConfirmation( + cUniV3_USDC_USDT_Strategy.connect(sDeployer)[initFunction]( + cVaultProxy.address, // Vault + assetAddresses.UniV3_USDC_USDT_Pool, // Pool address + assetAddresses.UniV3PositionManager, // NonfungiblePositionManager + dUniswapV3Helper.address, + assetAddresses.UniV3SwapRouter, + operatorAddr, + utils.parseEther("1000000", 18), // 1M, max TVL + utils.parseEther("50000", 18), // 50k, lost value threshold + await getTxOpts() + ) + ); + + // 5. Set LiquidityManager Implementation + await withConfirmation( + cUniV3_USDC_USDT_Strategy + .connect(sDeployer) + .setLiquidityManagerImpl(dUniV3PoolLiquidityManager.address) + ); + + // 6. Transfer governance + await withConfirmation( + cUniV3_USDC_USDT_Strategy + .connect(sDeployer) + .transferGovernance(timelockAddr, await getTxOpts()) + ); + + console.log( + "Uniswap V3 (USDC-USDT pool) strategy address: ", + cUniV3_USDC_USDT_Strategy.address + ); + // Governance Actions + // ---------------- + return { + name: "Deploy new Uniswap V3 (USDC-USDT pool) strategy", + actions: [ + // 0. Set VaultCore implementation + { + contract: cVaultProxy, + signature: "upgradeTo(address)", + args: [dVaultCore.address], + }, + // 0. Set VaultAdmin implementation + { + contract: cVault, + signature: "setAdminImpl(address)", + args: [dVaultAdmin.address], + }, + // 1. Accept governance of new cUniV3_USDC_USDT_Strategy + { + contract: cUniV3_USDC_USDT_Strategy, + signature: "claimGovernance()", + args: [], + }, + // 2. Add new strategy to vault + { + contract: cVaultAdmin, + signature: "approveUniswapV3Strategy(address)", + args: [cUniV3_USDC_USDT_Proxy.address], + }, + // 3. Set supported strategy on Harvester + { + contract: cHarvester, + signature: "setSupportedStrategy(address,bool)", + args: [cUniV3_USDC_USDT_Proxy.address, true], + }, + // 4. Set harvester address + { + contract: cUniV3_USDC_USDT_Strategy, + signature: "setHarvesterAddress(address)", + args: [cHarvesterProxy.address], + }, + // 5. Set Reserve Strategy for USDC + { + contract: cUniV3_USDC_USDT_Strategy, + signature: "setReserveStrategy(address,address)", + args: [assetAddresses.USDC, cAaveProxy.address], + }, + // 6. Set Reserve Strategy for USDT + { + contract: cUniV3_USDC_USDT_Strategy, + signature: "setReserveStrategy(address,address)", + args: [assetAddresses.USDT, cAaveProxy.address], + }, + // 7. Set Minimum Deposit threshold for USDC + { + contract: cUniV3_USDC_USDT_Strategy, + signature: "setMinDepositThreshold(address,uint256)", + args: [assetAddresses.USDC, utils.parseUnits("30000", 6)], // 30k + }, + // 8. Set Minimum Deposit threshold for USDT + { + contract: cUniV3_USDC_USDT_Strategy, + signature: "setMinDepositThreshold(address,uint256)", + args: [assetAddresses.USDT, utils.parseUnits("30000", 6)], // 30k + }, + // 9. Set Rebalance Price Threshold + { + contract: cUniV3_USDC_USDT_Strategy, + signature: "setRebalancePriceThreshold(int24,int24)", + args: [-1000, 1000], + }, + // 10. Set Swap price threshold + { + contract: cUniV3_USDC_USDT_Strategy, + signature: "setSwapPriceThreshold(int24,int24)", + args: [-1000, 1000], + }, + ], + }; + } +); diff --git a/contracts/docs/UniswapV3StrategyHierarchy.svg b/contracts/docs/UniswapV3StrategyHierarchy.svg new file mode 100644 index 0000000000..39db2d7dc4 --- /dev/null +++ b/contracts/docs/UniswapV3StrategyHierarchy.svg @@ -0,0 +1,94 @@ + + + + + + +UmlClassDiagram + + + +7 + +Governable +../contracts/governance/Governable.sol + + + +207 + +UniswapV3LiquidityManager +../contracts/strategies/uniswap/UniswapV3LiquidityManager.sol + + + +210 + +<<Abstract>> +UniswapV3StrategyStorage +../contracts/strategies/uniswap/UniswapV3StrategyStorage.sol + + + +207->210 + + + + + +209 + +UniswapV3Strategy +../contracts/strategies/uniswap/UniswapV3Strategy.sol + + + +209->210 + + + + + +156 + +<<Abstract>> +InitializableAbstractStrategy +../contracts/utils/InitializableAbstractStrategy.sol + + + +210->156 + + + + + +155 + +<<Abstract>> +Initializable +../contracts/utils/Initializable.sol + + + +156->7 + + + + + +156->155 + + + + + +156->156 + + + + + diff --git a/contracts/docs/UniswapV3StrategySquashed.svg b/contracts/docs/UniswapV3StrategySquashed.svg new file mode 100644 index 0000000000..a401150aed --- /dev/null +++ b/contracts/docs/UniswapV3StrategySquashed.svg @@ -0,0 +1,162 @@ + + + + + + +UmlClassDiagram + + + +209 + +UniswapV3Strategy +../contracts/strategies/uniswap/UniswapV3Strategy.sol + +Private: +   initialized: bool <<Initializable>> +   initializing: bool <<Initializable>> +   ______gap: uint256[50] <<Initializable>> +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +   _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>> +   _deprecated_rewardTokenAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardLiquidationThreshold: uint256 <<InitializableAbstractStrategy>> +   harvesterAddress: address <<InitializableAbstractStrategy>> +   rewardTokenAddresses: address[] <<InitializableAbstractStrategy>> +   _self: IUniswapV3Strategy <<UniswapV3StrategyStorage>> +   operatorAddr: address <<UniswapV3StrategyStorage>> +   token0: address <<UniswapV3StrategyStorage>> +   token1: address <<UniswapV3StrategyStorage>> +   reserveStrategy0: IStrategy <<UniswapV3StrategyStorage>> +   reserveStrategy1: IStrategy <<UniswapV3StrategyStorage>> +   poolFee: uint24 <<UniswapV3StrategyStorage>> +   swapsPaused: bool <<UniswapV3StrategyStorage>> +   rebalancePaused: bool <<UniswapV3StrategyStorage>> +   maxTVL: uint256 <<UniswapV3StrategyStorage>> +   minDepositThreshold0: uint256 <<UniswapV3StrategyStorage>> +   minDepositThreshold1: uint256 <<UniswapV3StrategyStorage>> +   minRebalanceTick: int24 <<UniswapV3StrategyStorage>> +   maxRebalanceTick: int24 <<UniswapV3StrategyStorage>> +   minSwapPriceX96: uint160 <<UniswapV3StrategyStorage>> +   maxSwapPriceX96: uint160 <<UniswapV3StrategyStorage>> +   activeTokenId: uint256 <<UniswapV3StrategyStorage>> +   netLostValue: uint256 <<UniswapV3StrategyStorage>> +   maxPositionValueLostThreshold: uint256 <<UniswapV3StrategyStorage>> +   pool: IUniswapV3Pool <<UniswapV3StrategyStorage>> +   positionManager: INonfungiblePositionManager <<UniswapV3StrategyStorage>> +   helper: IUniswapV3Helper <<UniswapV3StrategyStorage>> +   swapRouter: ISwapRouter <<UniswapV3StrategyStorage>> +   ticksToTokenId: mapping(int48=>uint256) <<UniswapV3StrategyStorage>> +   tokenIdToPosition: mapping(uint256=>Position) <<UniswapV3StrategyStorage>> +   LIQUIDITY_MANAGER_IMPL_POSITION: bytes32 <<UniswapV3StrategyStorage>> + +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _initialize(_platformAddress: address, _vaultAddress: address, _rewardTokenAddresses: address[], _assets: address[], _pTokens: address[]) <<InitializableAbstractStrategy>> +    _collectRewardTokens() <<InitializableAbstractStrategy>> +    _setPTokenAddress(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    _abstractSetPToken(_asset: address, address) <<UniswapV3Strategy>> +    _depositAll() <<UniswapV3StrategyStorage>> +    getPositionBalance(tokenId: uint256): (amount0: uint256, amount1: uint256) <<UniswapV3StrategyStorage>> +    getPositionPrincipal(tokenId: uint256): (amount0: uint256, amount1: uint256) <<UniswapV3StrategyStorage>> +    _setOperator(_operator: address) <<UniswapV3Strategy>> +    _setMaxTVL(_maxTVL: uint256) <<UniswapV3Strategy>> +    _setMaxPositionValueLostThreshold(_maxValueLostThreshold: uint256) <<UniswapV3Strategy>> +    _onlyPoolTokens(addr: address) <<UniswapV3Strategy>> +External: +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    initialize(_platformAddress: address, _vaultAddress: address, _rewardTokenAddresses: address[], _assets: address[], _pTokens: address[]) <<onlyGovernor, initializer>> <<InitializableAbstractStrategy>> +    collectRewardTokens() <<UniswapV3Strategy>> +    setRewardTokenAddresses(_rewardTokenAddresses: address[]) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    getRewardTokenAddresses(): address[] <<InitializableAbstractStrategy>> +    setPTokenAddress(address, address) <<UniswapV3Strategy>> +    removePToken(uint256) <<UniswapV3Strategy>> +    setHarvesterAddress(_harvesterAddress: address) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    safeApproveAllTokens() <<onlyGovernor, nonReentrant>> <<UniswapV3Strategy>> +    deposit(address, uint256) <<onlyVault, nonReentrant>> <<UniswapV3Strategy>> +    depositAll() <<onlyVault, nonReentrant>> <<UniswapV3Strategy>> +    withdraw(recipient: address, _asset: address, amount: uint256) <<onlyVault, nonReentrant>> <<UniswapV3Strategy>> +    withdrawAll() <<onlyVault, nonReentrant>> <<UniswapV3Strategy>> +    checkBalance(_asset: address): (balance: uint256) <<UniswapV3Strategy>> +    supportsAsset(_asset: address): bool <<UniswapV3Strategy>> +    initialize(_vaultAddress: address, _poolAddress: address, _nonfungiblePositionManager: address, _helper: address, _swapRouter: address, _operator: address, _maxTVL: uint256, _maxValueLostThreshold: uint256) <<onlyGovernor, initializer>> <<UniswapV3Strategy>> +    setOperator(_operator: address) <<onlyGovernorOrStrategist>> <<UniswapV3Strategy>> +    setReserveStrategy(_asset: address, _reserveStrategy: address) <<onlyGovernorOrStrategist, nonReentrant>> <<UniswapV3Strategy>> +    reserveStrategy(_asset: address): (reserveStrategyAddr: address) <<UniswapV3Strategy>> +    setMinDepositThreshold(_asset: address, _minThreshold: uint256) <<onlyGovernorOrStrategist>> <<UniswapV3Strategy>> +    setRebalancePaused(_paused: bool) <<onlyGovernorOrStrategist>> <<UniswapV3Strategy>> +    setSwapsPaused(_paused: bool) <<onlyGovernorOrStrategist>> <<UniswapV3Strategy>> +    setMaxTVL(_maxTVL: uint256) <<onlyGovernorOrStrategist>> <<UniswapV3Strategy>> +    setMaxPositionValueLostThreshold(_maxValueLostThreshold: uint256) <<onlyGovernorOrStrategist>> <<UniswapV3Strategy>> +    resetLostValue() <<onlyGovernor>> <<UniswapV3Strategy>> +    setRebalancePriceThreshold(minTick: int24, maxTick: int24) <<onlyGovernorOrStrategist>> <<UniswapV3Strategy>> +    setSwapPriceThreshold(minTick: int24, maxTick: int24) <<onlyGovernorOrStrategist>> <<UniswapV3Strategy>> +    getPendingFees(): (amount0: uint256, amount1: uint256) <<UniswapV3Strategy>> +    onERC721Received(address, address, uint256, bytes): bytes4 <<UniswapV3Strategy>> +    resetAllowanceOfTokens() <<onlyGovernor, nonReentrant>> <<UniswapV3Strategy>> +    setLiquidityManagerImpl(newImpl: address) <<onlyGovernor>> <<UniswapV3Strategy>> +    null() <<UniswapV3Strategy>> +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>> OperatorChanged(_address: address) <<UniswapV3StrategyStorage>> +    <<event>> LiquidityManagerImplementationUpgraded(_newImpl: address) <<UniswapV3StrategyStorage>> +    <<event>> ReserveStrategyChanged(asset: address, reserveStrategy: address) <<UniswapV3StrategyStorage>> +    <<event>> MinDepositThresholdChanged(asset: address, minDepositThreshold: uint256) <<UniswapV3StrategyStorage>> +    <<event>> RebalancePauseStatusChanged(paused: bool) <<UniswapV3StrategyStorage>> +    <<event>> SwapsPauseStatusChanged(paused: bool) <<UniswapV3StrategyStorage>> +    <<event>> RebalancePriceThresholdChanged(minTick: int24, maxTick: int24) <<UniswapV3StrategyStorage>> +    <<event>> SwapPriceThresholdChanged(minTick: int24, minSwapPriceX96: uint160, maxTick: int24, maxSwapPriceX96: uint160) <<UniswapV3StrategyStorage>> +    <<event>> MaxTVLChanged(maxTVL: uint256) <<UniswapV3StrategyStorage>> +    <<event>> MaxValueLostThresholdChanged(amount: uint256) <<UniswapV3StrategyStorage>> +    <<event>> NetLostValueReset(_by: address) <<UniswapV3StrategyStorage>> +    <<event>> NetLostValueChanged(currentNetLostValue: uint256) <<UniswapV3StrategyStorage>> +    <<event>> PositionValueChanged(tokenId: uint256, initialValue: uint256, currentValue: uint256, delta: int256) <<UniswapV3StrategyStorage>> +    <<event>> AssetSwappedForRebalancing(tokenIn: address, tokenOut: address, amountIn: uint256, amountOut: uint256) <<UniswapV3StrategyStorage>> +    <<event>> UniswapV3LiquidityAdded(tokenId: uint256, amount0Sent: uint256, amount1Sent: uint256, liquidityMinted: uint128) <<UniswapV3StrategyStorage>> +    <<event>> UniswapV3LiquidityRemoved(tokenId: uint256, amount0Received: uint256, amount1Received: uint256, liquidityBurned: uint128) <<UniswapV3StrategyStorage>> +    <<event>> UniswapV3PositionMinted(tokenId: uint256, lowerTick: int24, upperTick: int24) <<UniswapV3StrategyStorage>> +    <<event>> UniswapV3PositionClosed(tokenId: uint256, amount0Received: uint256, amount1Received: uint256) <<UniswapV3StrategyStorage>> +    <<event>> UniswapV3FeeCollected(tokenId: uint256, amount0: uint256, amount1: uint256) <<UniswapV3StrategyStorage>> +    <<modifier>> initializer() <<Initializable>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    <<modifier>> nonReentrantView() <<Governable>> +    <<modifier>> onlyVault() <<InitializableAbstractStrategy>> +    <<modifier>> onlyHarvester() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernor() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyGovernorOrStrategist() <<UniswapV3StrategyStorage>> +    <<modifier>> onlyGovernorOrStrategistOrOperator() <<UniswapV3StrategyStorage>> +    constructor() <<UniswapV3StrategyStorage>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    transferToken(_asset: address, _amount: uint256) <<onlyGovernor>> <<InitializableAbstractStrategy>> + + + diff --git a/contracts/docs/UniswapV3StrategyStorage.svg b/contracts/docs/UniswapV3StrategyStorage.svg new file mode 100644 index 0000000000..99c8364697 --- /dev/null +++ b/contracts/docs/UniswapV3StrategyStorage.svg @@ -0,0 +1,359 @@ + + + + + + +StorageDiagram + + + +6 + +UniswapV3Strategy <<Contract>> + +slot + +0 + +1-50 + +51 + +52 + +53 + +54 + +55 + +56 + +57 + +58 + +59-156 + +157 + +158 + +159 + +160 + +161 + +162 + +163 + +164 + +165 + +166 + +167 + +168 + +169 + +170 + +171 + +172 + +173 + +174 + +175 + +176 + +type: <inherited contract>.variable (bytes) + +unallocated (30) + +bool: Initializable.initializing (1) + +bool: Initializable.initialized (1) + +uint256[50]: Initializable.______gap (1600) + +unallocated (12) + +address: InitializableAbstractStrategy.platformAddress (20) + +unallocated (12) + +address: InitializableAbstractStrategy.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) + +unallocated (12) + +IUniswapV3Strategy: UniswapV3StrategyStorage._self (20) + +unallocated (12) + +address: UniswapV3StrategyStorage.operatorAddr (20) + +unallocated (12) + +address: UniswapV3StrategyStorage.token0 (20) + +unallocated (12) + +address: UniswapV3StrategyStorage.token1 (20) + +unallocated (12) + +IStrategy: UniswapV3StrategyStorage.reserveStrategy0 (20) + +unallocated (7) + +bool: UniswapV3StrategyStorage.rebalancePaused (1) + +bool: UniswapV3StrategyStorage.swapsPaused (1) + +uint24: UniswapV3StrategyStorage.poolFee (3) + +IStrategy: UniswapV3StrategyStorage.reserveStrategy1 (20) + +uint256: UniswapV3StrategyStorage.maxTVL (32) + +uint256: UniswapV3StrategyStorage.minDepositThreshold0 (32) + +uint256: UniswapV3StrategyStorage.minDepositThreshold1 (32) + +unallocated (6) + +uint160: UniswapV3StrategyStorage.minSwapPriceX96 (20) + +int24: UniswapV3StrategyStorage.maxRebalanceTick (3) + +int24: UniswapV3StrategyStorage.minRebalanceTick (3) + +unallocated (12) + +uint160: UniswapV3StrategyStorage.maxSwapPriceX96 (20) + +uint256: UniswapV3StrategyStorage.activeTokenId (32) + +uint256: UniswapV3StrategyStorage.netLostValue (32) + +uint256: UniswapV3StrategyStorage.maxPositionValueLostThreshold (32) + +unallocated (12) + +IUniswapV3Pool: UniswapV3StrategyStorage.pool (20) + +unallocated (12) + +INonfungiblePositionManager: UniswapV3StrategyStorage.positionManager (20) + +unallocated (12) + +IUniswapV3Helper: UniswapV3StrategyStorage.helper (20) + +unallocated (12) + +ISwapRouter: UniswapV3StrategyStorage.swapRouter (20) + +mapping(int48=>uint256): UniswapV3StrategyStorage.ticksToTokenId (32) + +mapping(uint256=>Position): UniswapV3StrategyStorage.tokenIdToPosition (32) + + + +1 + +uint256[50]: ______gap <<Array>> + +slot + +1 + +2 + +3-48 + +49 + +50 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1472) + +uint256 (32) + +uint256 (32) + + + +6:8->1 + + + + + +2 + +address[]: assetsMapped <<Array>> +0x4a11f94e20a93c79f6ec743a1954ec4fc2c08429ae2122118bf234b2185c81b8 + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +6:13->2 + + + + + +3 + +address[]: rewardTokenAddresses <<Array>> +0xa2999d817b6757290b50e8ecf3fa939673403dd35c97de392fdb343b4015ce9e + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +6:18->3 + + + + + +4 + +int256[98]: _reserved <<Array>> + +slot + +59 + +60 + +61-154 + +155 + +156 + +type: variable (bytes) + +int256 (32) + +int256 (32) + +---- (3008) + +int256 (32) + +int256 (32) + + + +6:24->4 + + + + + +5 + +Position <<Struct>> + +offset + +0 + +1 + +2 + +3 + +4 + +type: variable (bytes) + +uint256: tokenId (32) + +unallocated (9) + +bool: exists (1) + +int24: upperTick (3) + +int24: lowerTick (3) + +uint128: liquidity (16) + +unallocated (12) + +uint160: sqrtRatioAX96 (20) + +unallocated (12) + +uint160: sqrtRatioBX96 (20) + +uint256: netValue (32) + + + +6:57->5 + + + + + diff --git a/contracts/docs/generate.sh b/contracts/docs/generate.sh index 5f5badbd24..601094d788 100644 --- a/contracts/docs/generate.sh +++ b/contracts/docs/generate.sh @@ -67,6 +67,11 @@ sol2uml .. -v -hv -hf -he -hs -hl -b MorphoCompoundStrategy -o MorphoCompStrateg sol2uml .. -s -d 0 -b MorphoCompoundStrategy -o MorphoCompStrategySquashed.svg sol2uml storage .. -c MorphoCompoundStrategy -o MorphoCompStrategyStorage.svg +# contracts/strategies/uniswap/v3 +sol2uml .. -v -hv -hf -he -hs -hl -hi -b UniswapV3Strategy,UniswapV3LiquidityManager -o UniswapV3StrategyHierarchy.svg +sol2uml .. -s -d 0 -b UniswapV3Strategy -o UniswapV3StrategySquashed.svg +sol2uml storage .. -c UniswapV3Strategy -o UniswapV3StrategyStorage.svg + # contracts/timelock sol2uml .. -v -hv -hf -he -hs -hl -b Timelock -o TimelockHierarchy.svg sol2uml .. -s -d 0 -b Timelock -o TimelockSquashed.svg diff --git a/contracts/hardhat.config.js b/contracts/hardhat.config.js index bd47eba154..56a1f6029d 100644 --- a/contracts/hardhat.config.js +++ b/contracts/hardhat.config.js @@ -58,6 +58,7 @@ const MAINNET_GOVERNOR = "0x72426ba137dec62657306b12b1e869d43fec6ec7"; const MAINNET_MULTISIG = "0xbe2AB3d3d8F6a32b96414ebbd865dBD276d3d899"; const MAINNET_CLAIM_ADJUSTER = MAINNET_DEPLOYER; const MAINNET_STRATEGIST = "0xf14bbdf064e3f67f51cd9bd646ae3716ad938fdc"; +const MAINNET_OPERATOR = "0xf14bbdf064e3f67f51cd9bd646ae3716ad938fdc"; const mnemonic = "replace hover unaware super where filter stone fine garlic address matrix basic"; @@ -209,12 +210,20 @@ const isForkTest = module.exports = { solidity: { - version: "0.8.7", - settings: { - optimizer: { - enabled: true, + compilers: [ + { + version: "0.8.7", + settings: { + optimizer: { + enabled: true, + }, + }, }, - }, + { + // Uniswap V3 contracts use solc 0.7.6 + version: "0.7.6", + }, + ], }, networks: { hardhat: { @@ -338,6 +347,12 @@ module.exports = { hardhat: process.env.FORK === "true" ? MAINNET_STRATEGIST : 0, mainnet: MAINNET_STRATEGIST, }, + operatorAddr: { + default: 3, + localhost: process.env.FORK === "true" ? MAINNET_OPERATOR : 3, + hardhat: process.env.FORK === "true" ? MAINNET_OPERATOR : 3, + mainnet: MAINNET_OPERATOR, + }, }, contractSizer: { alphaSort: true, diff --git a/contracts/package.json b/contracts/package.json index 063f29fbda..85e8cbe231 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -41,8 +41,8 @@ "@nomiclabs/hardhat-waffle": "^2.0.1", "@openzeppelin/contracts": "4.4.2", "@openzeppelin/hardhat-upgrades": "^1.10.0", - "@uniswap/v3-core": "^1.0.0", - "@uniswap/v3-periphery": "^1.1.1", + "@uniswap/v3-core": "^1.0.1", + "@uniswap/v3-periphery": "^1.4.3", "chai": "^4.3.4", "debug": "^4.3.4", "dotenv": "^10.0.0", diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 70c19bfcfc..cb01b3f50b 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -39,7 +39,7 @@ const defaultFixture = deployments.createFixture(async () => { } ); - const { governorAddr, timelockAddr } = await getNamedAccounts(); + const { governorAddr, timelockAddr, operatorAddr } = await getNamedAccounts(); const ousdProxy = await ethers.getContract("OUSDProxy"); const vaultProxy = await ethers.getContract("VaultProxy"); @@ -120,6 +120,33 @@ const defaultFixture = deployments.createFixture(async () => { const buybackProxy = await ethers.getContract("BuybackProxy"); const buyback = await ethers.getContractAt("Buyback", buybackProxy.address); + const UniV3_USDC_USDT_Proxy = await ethers.getContract( + "UniV3_USDC_USDT_Proxy" + ); + const UniV3_USDC_USDT_Strategy = await ethers.getContractAt( + Array.from( + new Set([ + ...( + await ethers.getContractFactory("UniswapV3Strategy") + ).interface.format("full"), + ...( + await ethers.getContractFactory("UniswapV3LiquidityManager") + ).interface + .format("full") + .filter((x) => { + return !( + x.startsWith("function checkBalance(") || + x.startsWith("function supportsAsset(") || + x.startsWith("function deposit(") || + x.startsWith("function withdraw(") + ); + }), + ]) + ), + UniV3_USDC_USDT_Proxy.address + ); + const UniV3Helper = await ethers.getContract("UniswapV3Helper"); + let usdt, dai, tusd, @@ -175,6 +202,12 @@ const defaultFixture = deployments.createFixture(async () => { cvxRewardPool, LUSDMetaStrategyProxy, LUSDMetaStrategy, + UniV3PositionManager, + UniV3_USDC_USDT_Pool, + UniV3SwapRouter, + mockStrategy, + mockStrategy2, + mockStrategyDAI, oethHarvester, oethDripper, ConvexEthMetaStrategyProxy, @@ -242,6 +275,20 @@ const defaultFixture = deployments.createFixture(async () => { morphoAaveStrategyProxy.address ); + UniV3PositionManager = await ethers.getContractAt( + "INonfungiblePositionManager", + addresses.mainnet.UniV3PositionManager + ); + + UniV3_USDC_USDT_Pool = await ethers.getContractAt( + "IUniswapV3Pool", + addresses.mainnet.UniV3_USDC_USDT_Pool + ); + + UniV3SwapRouter = await ethers.getContractAt( + "ISwapRouter", + addresses.mainnet.UniV3SwapRouter + ); const oethMorphoAaveStrategyProxy = await ethers.getContract( "OETHMorphoAaveStrategyProxy" ); @@ -374,6 +421,16 @@ const defaultFixture = deployments.createFixture(async () => { LUSDMetaStrategyProxy.address ); + UniV3PositionManager = await ethers.getContract( + "MockNonfungiblePositionManager" + ); + UniV3_USDC_USDT_Pool = await ethers.getContract("MockUniswapV3Pool"); + mockStrategy = await ethers.getContract("MockStrategy"); + mockStrategy2 = await ethers.getContract("MockStrategy2"); + mockStrategyDAI = await ethers.getContract("MockStrategyDAI"); + + UniV3SwapRouter = await ethers.getContract("MockUniswapRouter"); + const fraxEthStrategyProxy = await ethers.getContract( "FraxETHStrategyProxy" ); @@ -402,6 +459,7 @@ const defaultFixture = deployments.createFixture(async () => { let governor = signers[1]; const strategist = signers[0]; const adjuster = signers[0]; + let operator = signers[3]; let timelock; const [matt, josh, anna, domen, daniel, franck] = signers.slice(4); @@ -409,6 +467,7 @@ const defaultFixture = deployments.createFixture(async () => { if (isFork) { governor = await impersonateAndFundContract(governorAddr); timelock = await impersonateAndFundContract(timelockAddr); + operator = await impersonateAndFundContract(operatorAddr); } await fundAccounts(); if (isFork) { @@ -444,6 +503,7 @@ const defaultFixture = deployments.createFixture(async () => { domen, daniel, franck, + operator, timelock, // Contracts ousd, @@ -516,6 +576,17 @@ const defaultFixture = deployments.createFixture(async () => { flipper, buyback, wousd, + + // Uniswap V3 Strategy + UniV3PositionManager, + UniV3_USDC_USDT_Pool, + UniV3_USDC_USDT_Strategy, + UniV3Helper, + UniV3SwapRouter, + mockStrategy, + mockStrategyDAI, + mockStrategy2, + //OETH oethVault, oeth, @@ -1076,6 +1147,7 @@ async function convexGeneralizedMetaForkedFixture( } async function impersonateAccount(address) { + address = address?.address || address; // Support passing contracts as well await hre.network.provider.request({ method: "hardhat_impersonateAccount", params: [address], @@ -1097,6 +1169,8 @@ async function _hardhatSetBalance(address, amount = "10000") { } async function impersonateAndFundContract(address, amount = "100000") { + address = address?.address || address; // Support passing contracts as well + await impersonateAccount(address); await _hardhatSetBalance(address, amount); @@ -1513,6 +1587,124 @@ async function rebornFixture() { return fixture; } +function uniswapV3FixtureSetup() { + return deployments.createFixture(async () => { + const fixture = await defaultFixture(); + + const { + usdc, + usdt, + dai, + UniV3_USDC_USDT_Strategy, + mockStrategy, + mockStrategy2, + mockStrategyDAI, + vault, + aaveStrategy, + } = fixture; + + const { governorAddr, timelockAddr } = await getNamedAccounts(); + const sGovernor = await ethers.provider.getSigner( + isFork ? timelockAddr : governorAddr + ); + + if (!isFork) { + // Approve mockStrategy + await _approveStrategy(fixture, mockStrategy); + await _approveStrategy(fixture, mockStrategy2); + await _approveStrategy(fixture, mockStrategyDAI); + + // Approve Uniswap V3 Strategy + await _approveStrategy(fixture, UniV3_USDC_USDT_Strategy, true); + + // Change default strategy to reserve strategies for both USDT and USDC + await _setDefaultStrategy(fixture, usdc, mockStrategy); + await _setDefaultStrategy(fixture, usdt, mockStrategy); + + // And a different one for DAI + await _setDefaultStrategy(fixture, dai, mockStrategyDAI); + + // Set reserve strategy + await UniV3_USDC_USDT_Strategy.setReserveStrategy( + usdc.address, + mockStrategy.address + ); + await UniV3_USDC_USDT_Strategy.setReserveStrategy( + usdt.address, + mockStrategy.address + ); + } else { + // Withdraw everything to Vault + await vault.connect(sGovernor).withdrawAllFromStrategies(); + + await _setDefaultStrategy(fixture, usdc, aaveStrategy); + await _setDefaultStrategy(fixture, usdt, aaveStrategy); + await _setDefaultStrategy(fixture, dai, aaveStrategy); + + await vault.connect(sGovernor).allocate(); + await vault.connect(sGovernor).rebase(); + } + + // await UniV3_USDC_USDT_Strategy.setSwapPriceThreshold(-1000, 1000); + + if (!isFork) { + UniV3_USDC_USDT_Strategy.connect(sGovernor) + // 2 million + .setMaxTVL(utils.parseUnits("2", 24)); + UniV3_USDC_USDT_Strategy.connect(sGovernor).setRebalancePriceThreshold( + -10000, + 10000 + ); + } else { + const [, activeTick] = await fixture.UniV3_USDC_USDT_Pool.slot0(); + await UniV3_USDC_USDT_Strategy.connect( + sGovernor + ).setRebalancePriceThreshold(activeTick - 10000, activeTick + 10000); + await UniV3_USDC_USDT_Strategy.connect(sGovernor).setSwapPriceThreshold( + activeTick - 10000, + activeTick + 10000 + ); + await UniV3_USDC_USDT_Strategy.connect( + sGovernor + ).setMaxPositionValueLostThreshold(utils.parseUnits("50000", 18)); + UniV3_USDC_USDT_Strategy.connect(sGovernor).setMaxTVL( + utils.parseUnits("2000000", 18) + ); // 2M + } + + return fixture; + }); +} + +async function _approveStrategy(fixture, strategy, isUniswapV3) { + const { vault, harvester } = fixture; + const { governorAddr, timelockAddr } = await getNamedAccounts(); + const sGovernor = await ethers.provider.getSigner( + isFork ? timelockAddr : governorAddr + ); + + if (isUniswapV3) { + await vault.connect(sGovernor).approveUniswapV3Strategy(strategy.address); + } else { + await vault.connect(sGovernor).approveStrategy(strategy.address); + } + + await harvester + .connect(sGovernor) + .setSupportedStrategy(strategy.address, true); +} + +async function _setDefaultStrategy(fixture, asset, strategy) { + const { vault } = fixture; + const { governorAddr, timelockAddr } = await getNamedAccounts(); + const sGovernor = await ethers.provider.getSigner( + isFork ? timelockAddr : governorAddr + ); + await vault + .connect(sGovernor) + .setAssetDefaultStrategy(asset.address, strategy.address); +} + async function replaceContractAt(targetAddress, mockContract) { const signer = (await hre.ethers.getSigners())[0]; const mockCode = await signer.provider.getCode(mockContract.address); @@ -1545,6 +1737,7 @@ module.exports = { aaveVaultFixture, hackedVaultFixture, rebornFixture, + uniswapV3FixtureSetup, withImpersonatedAccount, impersonateAndFundContract, impersonateAccount, diff --git a/contracts/test/helpers.js b/contracts/test/helpers.js index f5e30b0b2a..e6ce2aa8f7 100644 --- a/contracts/test/helpers.js +++ b/contracts/test/helpers.js @@ -44,6 +44,27 @@ chai.Assertion.addMethod( } ); +chai.Assertion.addMethod("totalSupplyOf", async function (expected, message) { + const contract = this._obj; + const actual = await contract.totalSupply(); + if (!BigNumber.isBigNumber(expected)) { + expected = parseUnits(expected, await decimalsFor(contract)); + } + chai.expect(actual).to.equal(expected, message); +}); + +chai.Assertion.addMethod( + "approxTotalSupplyOf", + async function (expected, message) { + const contract = this._obj; + const actual = await contract.totalSupply(); + if (!BigNumber.isBigNumber(expected)) { + expected = parseUnits(expected, await decimalsFor(contract)); + } + chai.expect(actual).to.approxEqualTolerance(expected, 1, message); + } +); + chai.Assertion.addMethod( "approxBalanceOf", async function (expected, contract, message) { @@ -63,6 +84,21 @@ chai.Assertion.addMethod( * * @param {Contract} contract - The token contract to check the balance of. */ +chai.Assertion.addMethod( + "approxBalanceWithToleranceOf", + async function (expected, contract, tolerancePct = 1, message = undefined) { + const user = this._obj; + const address = user.address || user.getAddress(); // supports contracts too + const actual = await contract.balanceOf(address); + if (!BigNumber.isBigNumber(expected)) { + expected = parseUnits(expected, await decimalsFor(contract)); + } + chai + .expect(actual) + .to.approxEqualTolerance(expected, tolerancePct, message); + } +); + chai.Assertion.addMethod( "balanceOf", async function (expected, contract, message) { @@ -449,6 +485,10 @@ const getAssetAddresses = async (deployments) => { uniswapRouter: addresses.mainnet.uniswapRouter, uniswapV3Router: addresses.mainnet.uniswapV3Router, sushiswapRouter: addresses.mainnet.sushiswapRouter, + + UniV3PositionManager: addresses.mainnet.UniV3PositionManager, + UniV3_USDC_USDT_Pool: addresses.mainnet.UniV3_USDC_USDT_Pool, + UniV3SwapRouter: addresses.mainnet.UniV3SwapRouter, }; } else { const addressMap = { @@ -487,6 +527,12 @@ const getAssetAddresses = async (deployments) => { uniswapRouter: (await deployments.get("MockUniswapRouter")).address, uniswapV3Router: (await deployments.get("MockUniswapRouter")).address, sushiswapRouter: (await deployments.get("MockUniswapRouter")).address, + + UniV3PositionManager: ( + await deployments.get("MockNonfungiblePositionManager") + ).address, + UniV3_USDC_USDT_Pool: (await ethers.getContract("MockUniswapV3Pool")) + .address, }; try { diff --git a/contracts/test/strategies/uniswap-v3.fork-test.js b/contracts/test/strategies/uniswap-v3.fork-test.js new file mode 100644 index 0000000000..b92a4a22af --- /dev/null +++ b/contracts/test/strategies/uniswap-v3.fork-test.js @@ -0,0 +1,865 @@ +const { expect } = require("chai"); +const { + uniswapV3FixtureSetup, + impersonateAndFundContract, + defaultFixture, +} = require("../_fixture"); +const { + forkOnlyDescribe, + units, + ousdUnits, + usdcUnitsFormat, + usdtUnitsFormat, + daiUnits, + daiUnitsFormat, + getBlockTimestamp, +} = require("../helpers"); +const { BigNumber, utils } = require("ethers"); + +forkOnlyDescribe("Uniswap V3 Strategy", function () { + after(async () => { + // This is needed to revert fixtures + // The other tests as of now don't use proper fixtures + // Rel: https://github.com/OriginProtocol/origin-dollar/issues/1259 + await defaultFixture(); + }); + + this.timeout(0); + + const uniswapV3Fixture = uniswapV3FixtureSetup(); + + let fixture; + let vault, ousd, usdc, usdt, dai; + let reserveStrategy, strategy, pool, positionManager, v3Helper, swapRouter; + let timelock; + // governor, + // strategist, + // harvester + let operator, josh, matt, daniel, domen, franck; + + beforeEach(async () => { + fixture = await uniswapV3Fixture(); + reserveStrategy = fixture.aaveStrategy; + strategy = fixture.UniV3_USDC_USDT_Strategy; + pool = fixture.UniV3_USDC_USDT_Pool; + positionManager = fixture.UniV3PositionManager; + v3Helper = fixture.UniV3Helper; + swapRouter = fixture.UniV3SwapRouter; + + ousd = fixture.ousd; + usdc = fixture.usdc; + usdt = fixture.usdt; + dai = fixture.dai; + vault = fixture.vault; + // harvester = fixture.harvester; + // governor = fixture.governor; + // strategist = fixture.strategist; + operator = fixture.operator; + timelock = fixture.timelock; + josh = fixture.josh; + matt = fixture.matt; + daniel = fixture.daniel; + domen = fixture.domen; + franck = fixture.franck; + }); + + async function setRebalancePriceThreshold(lowerTick, upperTick) { + await strategy + .connect(timelock) + .setRebalancePriceThreshold(lowerTick, upperTick); + } + + async function setMaxTVL(maxTvl) { + await strategy.connect(timelock).setMaxTVL(utils.parseUnits(maxTvl, 18)); + } + + async function setMaxPositionValueLostThreshold(maxLossThreshold) { + await strategy + .connect(timelock) + .setMaxPositionValueLostThreshold(utils.parseUnits(maxLossThreshold, 18)); + } + + describe("Uniswap V3 LP positions", function () { + // NOTE: These tests all work on the assumption that the strategy + // has no active position, which might not be true after deployment. + // Gotta update the tests before that + + const findMaxDepositableAmount = async ( + lowerTick, + upperTick, + usdcAmount, + usdtAmount + ) => { + const [sqrtRatioX96] = await pool.slot0(); + const sqrtRatioAX96 = await v3Helper.getSqrtRatioAtTick(lowerTick); + const sqrtRatioBX96 = await v3Helper.getSqrtRatioAtTick(upperTick); + + const liquidity = await v3Helper.getLiquidityForAmounts( + sqrtRatioX96, + sqrtRatioAX96, + sqrtRatioBX96, + usdcAmount, + usdtAmount + ); + + const [maxAmount0, maxAmount1] = await v3Helper.getAmountsForLiquidity( + sqrtRatioX96, + sqrtRatioAX96, + sqrtRatioBX96, + liquidity + ); + + return [maxAmount0, maxAmount1]; + }; + + const mintLiquidity = async ( + lowerTick, + upperTick, + usdcAmount, + usdtAmount + ) => { + const [maxUSDC, maxUSDT] = await findMaxDepositableAmount( + lowerTick, + upperTick, + BigNumber.from(usdcAmount).mul(10 ** 6), + BigNumber.from(usdtAmount).mul(10 ** 6) + ); + + const tx = await strategy + .connect(operator) + .rebalance( + maxUSDC, + maxUSDT, + maxUSDC.mul(9900).div(10000), + maxUSDT.mul(9900).div(10000), + 0, + 0, + lowerTick, + upperTick + ); + + const { events } = await tx.wait(); + + const [tokenId, amount0Minted, amount1Minted, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + return { + tokenId, + amount0Minted, + amount1Minted, + liquidityMinted, + tx, + }; + }; + + const increaseLiquidity = async (tokenId, usdcAmount, usdtAmount) => { + const storedPosition = await strategy.tokenIdToPosition(tokenId); + const [maxUSDC, maxUSDT] = await findMaxDepositableAmount( + storedPosition.lowerTick, + storedPosition.upperTick, + BigNumber.from(usdcAmount).mul(10 ** 6), + BigNumber.from(usdtAmount).mul(10 ** 6) + ); + + const tx = await strategy + .connect(operator) + .increaseActivePositionLiquidity( + maxUSDC, + maxUSDT, + maxUSDC.mul(9900).div(10000), + maxUSDT.mul(9900).div(10000) + ); + + const { events } = await tx.wait(); + + const [, amount0Added, amount1Added, liquidityAdded] = events.find( + (e) => e.event == "UniswapV3LiquidityAdded" + ).args; + + return { + tokenId, + amount0Added, + amount1Added, + liquidityAdded, + tx, + }; + }; + + const mintLiquidityBySwapping = async ( + lowerTick, + upperTick, + usdcAmount, + usdtAmount, + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne + ) => { + const [maxUSDC, maxUSDT] = await findMaxDepositableAmount( + lowerTick, + upperTick, + BigNumber.from(usdcAmount).mul(10 ** 6), + BigNumber.from(usdtAmount).mul(10 ** 6) + ); + + const tx = await strategy.connect(operator).swapAndRebalance({ + desiredAmount0: maxUSDC, + desiredAmount1: maxUSDT, + minAmount0: maxUSDC.mul(9900).div(10000), + minAmount1: maxUSDT.mul(9900).div(10000), + minRedeemAmount0: 0, + minRedeemAmount1: 0, + lowerTick, + upperTick, + swapAmountIn: BigNumber.from(swapAmountIn).mul(10 ** 6), + swapMinAmountOut: BigNumber.from(swapMinAmountOut).mul(10 ** 6), + sqrtPriceLimitX96, + swapZeroForOne, + }); + + const { events } = await tx.wait(); + + const [tokenId, amount0Minted, amount1Minted, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + return { + tokenId, + amount0Minted, + amount1Minted, + liquidityMinted, + tx, + }; + }; + + async function _swap(user, amount, zeroForOne) { + const [, activeTick] = await pool.slot0(); + const sqrtPriceLimitX96 = await v3Helper.getSqrtRatioAtTick( + activeTick + (zeroForOne ? -2 : 2) + ); + const swapAmount = BigNumber.from(amount).mul(1e6); + usdc.connect(user).approve(swapRouter.address, 0); + usdt.connect(user).approve(swapRouter.address, 0); + usdc.connect(user).approve(swapRouter.address, swapAmount.mul(1e3)); + usdt.connect(user).approve(swapRouter.address, swapAmount.mul(1e3)); + await swapRouter.connect(user).exactInputSingle([ + zeroForOne ? usdc.address : usdt.address, // tokenIn + zeroForOne ? usdt.address : usdc.address, // tokenOut + 100, // fee + user.address, // recipient + (await getBlockTimestamp()) + 10, // deadline + swapAmount, // amountIn + 0, // amountOutMinimum + sqrtPriceLimitX96, + ]); + } + + it("Should mint position", async () => { + const usdcBalBefore = await strategy.checkBalance(usdc.address); + const usdtBalBefore = await strategy.checkBalance(usdt.address); + + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick - 1000; + const upperTick = activeTick + 1000; + + const { tokenId, amount0Minted, amount1Minted, liquidityMinted, tx } = + await mintLiquidity(lowerTick, upperTick, "100000", "100000"); + + // Check events + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + await expect(tx).to.have.emittedEvent("UniswapV3LiquidityAdded"); + + // Check minted position data + const nfp = await positionManager.positions(tokenId); + expect(nfp.token0).to.equal(usdc.address, "Invalid token0 address"); + expect(nfp.token1).to.equal(usdt.address, "Invalid token1 address"); + expect(nfp.tickLower).to.equal(lowerTick, "Invalid lower tick"); + expect(nfp.tickUpper).to.equal(upperTick, "Invalid upper tick"); + + // Check Strategy balance + const usdcBalAfter = await strategy.checkBalance(usdc.address); + const usdtBalAfter = await strategy.checkBalance(usdt.address); + expect(usdcBalAfter).gte( + usdcBalBefore, + "Expected USDC balance to have increased" + ); + expect(usdtBalAfter).gte( + usdtBalBefore, + "Expected USDT balance to have increased" + ); + expect(usdcBalAfter).to.approxEqual( + usdcBalBefore.add(amount0Minted), + "Deposited USDC mismatch" + ); + expect(usdtBalAfter).to.approxEqual( + usdtBalBefore.add(amount1Minted), + "Deposited USDT mismatch" + ); + + // Check data on strategy + const storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.exists).to.be.true; + expect(storedPosition.tokenId).to.equal(tokenId); + expect(storedPosition.lowerTick).to.equal(lowerTick); + expect(storedPosition.upperTick).to.equal(upperTick); + expect(storedPosition.liquidity).to.equal(liquidityMinted); + expect(storedPosition.netValue).to.equal( + amount0Minted.add(amount1Minted).mul(BigNumber.from(1e12)) + ); + expect(await strategy.activeTokenId()).to.equal(tokenId); + }); + + it("Should not mint if the position is out of hard boundary tick range", async () => { + await setRebalancePriceThreshold(-5, 5); + + const lowerTick = -10; + const upperTick = 10; + + await expect( + mintLiquidity(lowerTick, upperTick, "100000", "100000") + ).to.be.revertedWith("Rebalance position out of bounds"); + }); + + it("Should not mint if the position surpasses the maxTVL amount", async () => { + // set max TVL of 100 units (denominated in 18 decimals) + await setMaxTVL("100"); + + const lowerTick = -10; + const upperTick = 10; + + await expect( + mintLiquidity(lowerTick, upperTick, "100000", "100000") + ).to.be.revertedWith("MaxTVL threshold has been reached"); + }); + + it("Should swap USDC for USDT and mint position", async () => { + // Move all USDT out of reserve + await reserveStrategy + .connect(await impersonateAndFundContract(vault.address)) + .withdraw( + vault.address, + usdt.address, + await reserveStrategy.checkBalance(usdt.address) + ); + + const usdcBalBefore = await strategy.checkBalance(usdc.address); + const usdtBalBefore = await strategy.checkBalance(usdt.address); + + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick - 1000; + const upperTick = activeTick + 1000; + + const swapAmountIn = "101000"; + const swapAmountOut = "100000"; + const sqrtPriceLimitX96 = v3Helper.getSqrtRatioAtTick(activeTick - 50); + const swapZeroForOne = true; + + const { tokenId, amount0Minted, amount1Minted, liquidityMinted, tx } = + await mintLiquidityBySwapping( + lowerTick, + upperTick, + "100000", + "100000", + swapAmountIn, + swapAmountOut, + sqrtPriceLimitX96, + swapZeroForOne + ); + + // Check events + await expect(tx).to.have.emittedEvent("AssetSwappedForRebalancing"); + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + await expect(tx).to.have.emittedEvent("UniswapV3LiquidityAdded"); + + // Check minted position data + const nfp = await positionManager.positions(tokenId); + expect(nfp.token0).to.equal(usdc.address, "Invalid token0 address"); + expect(nfp.token1).to.equal(usdt.address, "Invalid token1 address"); + expect(nfp.tickLower).to.equal(lowerTick, "Invalid lower tick"); + expect(nfp.tickUpper).to.equal(upperTick, "Invalid upper tick"); + + // Check Strategy balance + const usdcBalAfter = await strategy.checkBalance(usdc.address); + const usdtBalAfter = await strategy.checkBalance(usdt.address); + expect(usdcBalAfter).gte( + usdcBalBefore, + "Expected USDC balance to have increased" + ); + expect(usdtBalAfter).gte( + usdtBalBefore, + "Expected USDT balance to have increased" + ); + // expect(usdcBalAfter).to.approxEqualTolerance( + // usdcBalBefore.add(amount0Minted), + // 1, + // "Deposited USDC mismatch" + // ); + // expect(usdtBalAfter).to.approxEqualTolerance( + // usdtBalBefore.add(amount1Minted), + // 1, + // "Deposited USDT mismatch" + // ); + + // Check data on strategy + const storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.exists).to.be.true; + expect(storedPosition.tokenId).to.equal(tokenId); + expect(storedPosition.lowerTick).to.equal(lowerTick); + expect(storedPosition.upperTick).to.equal(upperTick); + expect(storedPosition.liquidity).to.equal(liquidityMinted); + expect(storedPosition.netValue).to.equal( + amount0Minted.add(amount1Minted).mul(BigNumber.from(1e12)) + ); + expect(await strategy.activeTokenId()).to.equal(tokenId); + }); + + it("Should swap USDT for USDC and mint position", async () => { + // Move all USDC out of reserve + await reserveStrategy + .connect(await impersonateAndFundContract(vault.address)) + .withdraw( + vault.address, + usdc.address, + await reserveStrategy.checkBalance(usdc.address) + ); + + const usdcBalBefore = await strategy.checkBalance(usdc.address); + const usdtBalBefore = await strategy.checkBalance(usdt.address); + + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick - 1000; + const upperTick = activeTick + 1000; + + const swapAmountIn = "101000"; + const swapAmountOut = "100000"; + const sqrtPriceLimitX96 = v3Helper.getSqrtRatioAtTick(activeTick + 50); + const swapZeroForOne = false; + + const { tokenId, amount0Minted, amount1Minted, liquidityMinted, tx } = + await mintLiquidityBySwapping( + lowerTick, + upperTick, + "100000", + "100000", + swapAmountIn, + swapAmountOut, + sqrtPriceLimitX96, + swapZeroForOne + ); + + // Check events + await expect(tx).to.have.emittedEvent("AssetSwappedForRebalancing"); + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + await expect(tx).to.have.emittedEvent("UniswapV3LiquidityAdded"); + + // Check minted position data + const nfp = await positionManager.positions(tokenId); + expect(nfp.token0).to.equal(usdc.address, "Invalid token0 address"); + expect(nfp.token1).to.equal(usdt.address, "Invalid token1 address"); + expect(nfp.tickLower).to.equal(lowerTick, "Invalid lower tick"); + expect(nfp.tickUpper).to.equal(upperTick, "Invalid upper tick"); + + // Check Strategy balance + const usdcBalAfter = await strategy.checkBalance(usdc.address); + const usdtBalAfter = await strategy.checkBalance(usdt.address); + + expect(usdcBalAfter).gte( + usdcBalBefore, + "Expected USDC balance to have increased" + ); + expect(usdtBalAfter).gte( + usdtBalBefore, + "Expected USDT balance to have increased" + ); + // expect(usdcBalAfter).to.approxEqual( + // usdcBalBefore.add(amount0Minted), + // "Deposited USDC mismatch" + // ); + // expect(usdtBalAfter).to.approxEqual( + // usdtBalBefore.add(amount1Minted), + // "Deposited USDT mismatch" + // ); + + // Check data on strategy + const storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.exists).to.be.true; + expect(storedPosition.tokenId).to.equal(tokenId); + expect(storedPosition.lowerTick).to.equal(lowerTick); + expect(storedPosition.upperTick).to.equal(upperTick); + expect(storedPosition.liquidity).to.equal(liquidityMinted); + expect(storedPosition.netValue).to.equal( + amount0Minted.add(amount1Minted).mul(BigNumber.from(1e12)) + ); + expect(await strategy.activeTokenId()).to.equal(tokenId); + }); + + it("Should increase liquidity of existing position", async () => { + const usdcBalBefore = await strategy.checkBalance(usdc.address); + const usdtBalBefore = await strategy.checkBalance(usdt.address); + + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick - 1003; + const upperTick = activeTick + 1005; + + const amount = "100000"; + const amountUnits = BigNumber.from(amount).mul(10 ** 6); + + // Mint position + const { tokenId, tx, amount0Minted, amount1Minted, liquidityMinted } = + await mintLiquidity(lowerTick, upperTick, amount, amount); + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + let storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.exists).to.be.true; + expect(storedPosition.liquidity).to.equal(liquidityMinted); + expect(storedPosition.netValue).to.equal( + amount0Minted.add(amount1Minted).mul(BigNumber.from(1e12)) + ); + expect(await strategy.activeTokenId()).to.equal(tokenId); + + // Rebalance again to increase liquidity + const { + tx: increaseTx, + amount0Added, + amount1Added, + liquidityAdded, + } = await increaseLiquidity(tokenId, amount, amount); + await expect(increaseTx).to.have.emittedEvent("UniswapV3LiquidityAdded"); + + // Check storage + storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.liquidity).to.approxEqual( + liquidityMinted.add(liquidityAdded) + ); + expect(storedPosition.netValue).to.approxEqual( + amount0Minted + .add(amount1Minted) + .add(amount0Added) + .add(amount1Added) + .mul(BigNumber.from(1e12)) + ); + + // Check balance on strategy + const usdcBalAfter = await strategy.checkBalance(usdc.address); + const usdtBalAfter = await strategy.checkBalance(usdt.address); + expect(usdcBalAfter).to.approxEqualTolerance( + usdcBalBefore.add(amountUnits.mul(2)), + 1, + "Deposited USDC mismatch" + ); + expect(usdtBalAfter).to.approxEqualTolerance( + usdtBalBefore.add(amountUnits.mul(2)), + 1, + "Deposited USDT mismatch" + ); + }); + + it("Should close LP position", async () => { + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick - 1003; + const upperTick = activeTick + 1005; + + const amount = "100000"; + + // Mint position + const { tokenId, tx } = await mintLiquidity( + lowerTick, + upperTick, + amount, + amount + ); + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + let storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.exists).to.be.true; + expect(await strategy.activeTokenId()).to.equal(tokenId); + + // Remove liquidity + const tx2 = await strategy.connect(operator).closePosition(tokenId, 0, 0); + await expect(tx2).to.have.emittedEvent("UniswapV3LiquidityRemoved"); + + // Check storage + storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.liquidity).to.be.equal(0); + expect(storedPosition.netValue).to.be.equal(0); + + expect(await strategy.activeTokenId()).to.equal( + BigNumber.from(0), + "Should have no active position" + ); + + // Check balance on strategy + const usdcBalAfter = await strategy.checkBalance(usdc.address); + const usdtBalAfter = await strategy.checkBalance(usdt.address); + await expect(strategy).to.have.an.approxBalanceOf(usdcBalAfter, usdc); + await expect(strategy).to.have.an.approxBalanceOf(usdtBalAfter, usdt); + }); + + it("Should collect fees", async () => { + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick - 12; + const upperTick = activeTick + 49; + + // Mint position + const amount = "100000"; + const { tokenId, tx } = await mintLiquidity( + lowerTick, + upperTick, + amount, + amount + ); + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + const storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.exists).to.be.true; + expect(await strategy.activeTokenId()).to.equal(tokenId); + + // Do some big swaps + await _swap(matt, "1000000", true); + await _swap(josh, "1000000", false); + await _swap(franck, "1000000", true); + await _swap(daniel, "1000000", false); + await _swap(domen, "1000000", true); + + // Check fee amounts + let [fee0, fee1] = await strategy.getPendingFees(); + expect(fee0).to.be.gt(0); + expect(fee1).to.be.gt(0); + + // Collect fees + await strategy.connect(operator).collectFees(); + [fee0, fee1] = await strategy.getPendingFees(); + expect(fee0).to.equal(0); + expect(fee1).to.equal(0); + }); + + it("Should not mint if net loss threshold is breached", async () => { + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick; + const upperTick = activeTick + 1; + + const slot0 = await hre.network.provider.request({ + method: "eth_getStorageAt", + params: [pool.address, "0x0"], + }); + + // Mint position + const amount = "100000"; + const { tokenId, tx, amount0Minted, amount1Minted, liquidityMinted } = + await mintLiquidity(lowerTick, upperTick, amount, amount); + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + let storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.exists).to.be.true; + expect(await strategy.activeTokenId()).to.equal(tokenId); + expect(await strategy.netLostValue()).to.equal(0); + + // Change active tick + await hre.network.provider.request({ + method: "hardhat_setStorageAt", + params: [ + pool.address, + "0x0", + `${slot0.slice(0, 20)}f111110000000000000000000000000000000000000000`, + ], + }); + + // Set threshold to a low value to see if it throws + await setMaxPositionValueLostThreshold("0.00000000000001"); + await expect( + strategy + .connect(operator) + .increaseActivePositionLiquidity("1000000", "1000000", "0", "0") + ).to.be.revertedWith("Over max value loss threshold"); + + // Set the threshold higher and make sure the net loss event is emitted + // and state updated properly + await setMaxPositionValueLostThreshold("1000000000000000"); + const { + tx: increaseTx, + amount0Added, + amount1Added, + liquidityAdded, + } = await increaseLiquidity(tokenId, "1", "1"); + await expect(increaseTx).to.have.emittedEvent("PositionValueChanged"); + + storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.liquidity).approxEqual( + liquidityMinted.add(liquidityAdded) + ); + const netLost = await strategy.netLostValue(); + expect(netLost).to.be.gte(0, "Expected lost value to have been updated"); + expect(storedPosition.netValue).to.be.lte( + amount0Minted + .add(amount1Minted) + .add(amount0Added) + .add(amount1Added) + .mul(1e12) + .sub(netLost) + ); + }); + + it("Should be allowed to close position when net loss threshold is breached", async () => { + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick; + const upperTick = activeTick + 1; + + const slot0 = await hre.network.provider.request({ + method: "eth_getStorageAt", + params: [pool.address, "0x0"], + }); + + // Mint position + const amount = "100000"; + const { tokenId, tx } = await mintLiquidity( + lowerTick, + upperTick, + amount, + amount + ); + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + let storedPosition = await strategy.tokenIdToPosition(tokenId); + expect(storedPosition.exists).to.be.true; + expect(await strategy.activeTokenId()).to.equal(tokenId); + expect(await strategy.netLostValue()).to.equal(0); + + // Change active tick + await hre.network.provider.request({ + method: "hardhat_setStorageAt", + params: [ + pool.address, + "0x0", + `${slot0.slice(0, 20)}f111110000000000000000000000000000000000000000`, + ], + }); + + // Set threshold to a low value to see if it throws + await setMaxPositionValueLostThreshold("0.00000000000001"); + await expect( + strategy + .connect(operator) + .increaseActivePositionLiquidity("1000000", "1000000", "0", "0") + ).to.be.revertedWith("Over max value loss threshold"); + + // should still be allowed to close the position + await strategy.connect(operator).closePosition(tokenId, "0", "0"); + }); + + it("netLostValue will catch possible pool tilts", async () => { + const [, activeTick] = await pool.slot0(); + const lowerTick = activeTick; + const upperTick = activeTick + 1; + let drainLoops = 10; + while (drainLoops > 0) { + const amount = "10000"; + await mintLiquidity(lowerTick, upperTick, amount, amount); + // Mint position + // Do some big swaps to move active tick + await _swap(daniel, "500000", false); + await _swap(josh, "500000", false); + + await mintLiquidity(lowerTick, upperTick, amount, amount); + + expect(await strategy.netLostValue()).gte( + 0, + "Expected netLostValue to be greater than 0" + ); + + await _swap(daniel, "500000", true); + await _swap(josh, "500000", true); + drainLoops--; + } + }); + }); + + describe("Sanity checks", () => { + describe("Mint", function () { + const mintTest = async (user, amount, asset) => { + const ousdAmount = ousdUnits(amount); + const tokenAmount = await units(amount, asset); + + const currentSupply = await ousd.totalSupply(); + const ousdBalance = await ousd.balanceOf(user.address); + const tokenBalance = await asset.balanceOf(user.address); + + // await asset.connect(user).approve(vault.address, tokenAmount) + await vault.connect(user).mint(asset.address, tokenAmount, 0); + + await expect(ousd).to.have.an.approxTotalSupplyOf( + currentSupply.add(ousdAmount), + "Total supply mismatch" + ); + await expect(user).to.have.an.approxBalanceWithToleranceOf( + ousdBalance.add(ousdAmount), + ousd, + 1, + "Should've minted equivalent OUSD" + ); + await expect(user).to.have.an.approxBalanceWithToleranceOf( + tokenBalance.sub(tokenAmount), + asset, + 1, + "Should've deposoited equivaluent other token" + ); + }; + + it("with USDC", async () => { + await mintTest(daniel, "30000", usdc); + }); + it("with USDT", async () => { + await mintTest(domen, "30000", usdt); + }); + it("with DAI", async () => { + await mintTest(franck, "30000", dai); + }); + }); + + describe("Redeem", function () { + const redeemTest = async (user, amount) => { + const ousdAmount = ousdUnits(amount); + + let ousdBalance = await ousd.balanceOf(user.address); + if (ousdBalance.lt(ousdAmount)) { + // Mint some OUSD + await vault.connect(user).mint(dai.address, daiUnits(amount), 0); + ousdBalance = await ousd.balanceOf(user.address); + } + + const currentSupply = await ousd.totalSupply(); + const usdcBalance = await usdc.balanceOf(user.address); + const usdtBalance = await usdt.balanceOf(user.address); + const daiBalance = await dai.balanceOf(user.address); + + await vault.connect(user).redeem(ousdAmount, 0); + + await expect(ousd).to.have.an.approxTotalSupplyOf( + currentSupply.sub(ousdAmount), + "Total supply mismatch" + ); + await expect(user).to.have.an.approxBalanceWithToleranceOf( + ousdBalance.sub(ousdAmount), + ousd, + 1, + "Should've burned equivalent OUSD" + ); + + const balanceDiff = + parseFloat( + usdcUnitsFormat((await usdc.balanceOf(user.address)) - usdcBalance) + ) + + parseFloat( + usdtUnitsFormat((await usdt.balanceOf(user.address)) - usdtBalance) + ) + + parseFloat( + daiUnitsFormat((await dai.balanceOf(user.address)) - daiBalance) + ); + + await expect(balanceDiff).to.approxEqualTolerance( + amount, + 1, + "Should've redeemed equivaluent other token" + ); + }; + + it.skip("Should withdraw from reserve strategy", async () => { + // Withdraw liquidates current position, so this test is no longer valid + redeemTest(josh, "10000"); + }); + }); + }); +}); diff --git a/contracts/test/strategies/uniswap-v3.js b/contracts/test/strategies/uniswap-v3.js new file mode 100644 index 0000000000..951340249e --- /dev/null +++ b/contracts/test/strategies/uniswap-v3.js @@ -0,0 +1,1679 @@ +const { expect } = require("chai"); +const { + uniswapV3FixtureSetup, + impersonateAndFundContract, + defaultFixture, +} = require("../_fixture"); +const { + units, + ousdUnits, + expectApproxSupply, + usdcUnits, + usdtUnits, +} = require("../helpers"); +const { deployments } = require("hardhat"); +const { BigNumber } = require("ethers"); + +describe("Uniswap V3 Strategy", function () { + after(async () => { + // This is needed to revert fixtures + // The other tests as of now don't use proper fixtures + // Rel: https://github.com/OriginProtocol/origin-dollar/issues/1259 + await defaultFixture(); + }); + + // Fixtures + const uniswapV3Fixture = uniswapV3FixtureSetup(); + + const liquidityManagerFixture = deployments.createFixture(async () => { + const fixture = await uniswapV3Fixture(); + const { franck, daniel, domen, vault, usdc, usdt, dai, UniV3SwapRouter } = + fixture; + + // Mint some liquidity + for (const user of [franck, daniel, domen, UniV3SwapRouter]) { + const isRouter = user.address == UniV3SwapRouter.address; + const account = isRouter + ? await impersonateAndFundContract(UniV3SwapRouter.address) + : user; + for (const asset of [usdc, usdt, dai]) { + const amount = isRouter ? "10000000000" : "1000000"; + await asset.connect(account).mint(await units(amount, asset)); + if (!isRouter) { + await asset + .connect(user) + .approve(vault.address, await units(amount, asset)); + await vault + .connect(user) + .mint(asset.address, await units(amount, asset), 0); + } + } + } + + // Configure mockPool + const { UniV3_USDC_USDT_Pool: mockPool, governor } = fixture; + await mockPool.connect(governor).setTick(-2); + + await fixture.UniV3_USDC_USDT_Strategy.connect( + governor + ).setMaxPositionValueLostThreshold(ousdUnits("50000", 18)); + + await fixture.UniV3_USDC_USDT_Strategy.connect( + governor + ).setSwapPriceThreshold(-100, 100); + + return fixture; + }); + + const activePositionFixture = deployments.createFixture(async () => { + const fixture = await liquidityManagerFixture(); + + // Mint a position + const { operator, UniV3_USDC_USDT_Strategy } = fixture; + await UniV3_USDC_USDT_Strategy.connect(operator).rebalance( + usdcUnits("100000"), + usdcUnits("100000"), + "0", + "0", + "0", + "0", + "-100", + "100" + ); + + return fixture; + }); + + // let fixture; + let vault, ousd, usdc, usdt, dai; + let reserveStrategy, + strategy, + helper, + mockPool, + mockPositionManager, + mockStrategy2, + mockStrategyDAI; + let governor, strategist, operator, josh, matt, daniel, domen, franck; + + const mint = async (user, amount, asset) => { + await asset.connect(user).mint(await units(amount, asset)); + await asset + .connect(user) + .approve(vault.address, await units(amount, asset)); + await vault + .connect(user) + .mint(asset.address, await units(amount, asset), 0); + }; + + function _destructureFixture(_fixture) { + // fixture = _fixture; + reserveStrategy = _fixture.mockStrategy; + mockStrategy2 = _fixture.mockStrategy2; + mockStrategyDAI = _fixture.mockStrategyDAI; + strategy = _fixture.UniV3_USDC_USDT_Strategy; + helper = _fixture.UniV3Helper; + mockPool = _fixture.UniV3_USDC_USDT_Pool; + mockPositionManager = _fixture.UniV3PositionManager; + // swapRotuer = _fixture.UniV3SwapRouter; + ousd = _fixture.ousd; + usdc = _fixture.usdc; + usdt = _fixture.usdt; + dai = _fixture.dai; + vault = _fixture.vault; + governor = _fixture.governor; + strategist = _fixture.strategist; + operator = _fixture.operator; + josh = _fixture.josh; + matt = _fixture.matt; + daniel = _fixture.daniel; + domen = _fixture.domen; + franck = _fixture.franck; + } + + for (const assetSymbol of ["USDC", "USDT"]) { + describe(`Mint w/ ${assetSymbol}`, function () { + let asset; + beforeEach(async () => { + _destructureFixture(await uniswapV3Fixture()); + asset = assetSymbol == "USDT" ? usdt : usdc; + }); + + it("Should mint w/o allocate", async () => { + // Vault has 200 DAI from fixtures + await expectApproxSupply(ousd, ousdUnits("200")); + await expect(vault).has.an.approxBalanceOf("200", dai); + // Mint some OUSD with USDC/USDT + await mint(daniel, "10000", asset); + await expectApproxSupply(ousd, ousdUnits("10200")); + // Make sure it's in vault + await expect(vault).has.an.approxBalanceOf("10000", asset); + }); + + it("Should mint and allocate to reserve strategy", async () => { + // Vault has 200 DAI from fixtures + await expectApproxSupply(ousd, ousdUnits("200")); + await expect(vault).has.an.approxBalanceOf("200", dai); + // Mint some OUSD with USDC/USDT + await mint(franck, "30000", asset); + await expectApproxSupply(ousd, ousdUnits("30200")); + // Make sure it went to reserve strategy + await expect(reserveStrategy).has.an.approxBalanceOf("30000", asset); + }); + }); + } + + describe("Deposit", function () { + it("Should revert direct deposits", async () => { + const impersonatedVaultSigner = await impersonateAndFundContract( + vault.address + ); + + await expect( + strategy.connect(impersonatedVaultSigner).deposit(usdc.address, "1") + ).to.be.revertedWith("Direct deposits disabled on UniswapV3Strategy"); + + await expect( + strategy.connect(impersonatedVaultSigner).depositAll() + ).to.be.revertedWith("Direct deposits disabled on UniswapV3Strategy"); + }); + }); + + describe("Redeem", function () { + beforeEach(async () => { + _destructureFixture(await uniswapV3Fixture()); + }); + + it("Should withdraw from vault balance", async () => { + // Vault has 200 DAI from fixtures + await expectApproxSupply(ousd, ousdUnits("200")); + await expect(vault).has.an.approxBalanceOf("200", dai); + // Mint some OUSD with USDC + await mint(domen, "10000", usdc); + + // Try redeem + await vault.connect(domen).redeem(ousdUnits("10000"), 0); + await expectApproxSupply(ousd, ousdUnits("200")); + }); + + it.skip("Should withdraw from reserve strategy", async () => { + // Withdraw liquidates current position, so this test is no longer valid + + // Vault has 200 DAI from fixtures + await expectApproxSupply(ousd, ousdUnits("200")); + await expect(vault).has.an.approxBalanceOf("200", dai); + + // Mint some OUSD with USDT + await mint(matt, "30000", usdt); + await expectApproxSupply(ousd, ousdUnits("30200")); + await expect(reserveStrategy).has.an.approxBalanceOf("30000", usdt); + + // Try redeem + await vault.connect(matt).redeem(ousdUnits("30000"), 0); + await expectApproxSupply(ousd, ousdUnits("200")); + }); + }); + + describe("Admin functions", () => { + beforeEach(async () => { + _destructureFixture(await uniswapV3Fixture()); + }); + + describe("setOperator()", () => { + it("Governor can set the operator", async () => { + const addr1 = "0x0000000000000000000000000000000000011111"; + await strategy.connect(governor).setOperator(addr1); + expect(await strategy.operatorAddr()).to.equal(addr1); + }); + it("Strategist can set the operator", async () => { + const addr1 = "0x0000000000000000000000000000000000011111"; + await strategy.connect(strategist).setOperator(addr1); + expect(await strategy.operatorAddr()).to.equal(addr1); + }); + it("Should not change operator if not governor/strategist", async () => { + const addr1 = "0x0000000000000000000000000000000000011111"; + await expect( + strategy.connect(operator).setOperator(addr1) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + await expect( + strategy.connect(josh).setOperator(addr1) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + }); + }); + + describe("setReserveStrategy()", () => { + beforeEach(async () => { + _destructureFixture(await uniswapV3Fixture()); + }); + + describe("Validations", () => { + it("Can set a valid strategy as reserve", async () => { + await strategy + .connect(governor) + .setReserveStrategy(usdc.address, mockStrategy2.address); + expect(await strategy.reserveStrategy(usdc.address)).to.equal( + mockStrategy2.address + ); + }); + it("Cannot set an unsupported strategy as reserve", async () => { + await expect( + strategy + .connect(governor) + .setReserveStrategy(usdc.address, mockStrategyDAI.address) + ).to.be.revertedWith("Invalid strategy for asset"); + }); + it("Cannot set reserve strategy for unsupported assets", async () => { + await expect( + strategy + .connect(governor) + .setReserveStrategy(dai.address, mockStrategyDAI.address) + ).to.be.revertedWith("Unsupported asset"); + }); + it("Cannot set an unapproved strategy as reserve", async () => { + await vault.connect(governor).removeStrategy(mockStrategy2.address); + + await expect( + strategy + .connect(governor) + .setReserveStrategy(usdc.address, mockStrategy2.address) + ).to.be.revertedWith("Unsupported strategy"); + }); + }); + describe("Permissions", () => { + it("Governor can change reserve strategy", async () => { + await strategy + .connect(governor) + .setReserveStrategy(usdc.address, mockStrategy2.address); + expect(await strategy.reserveStrategy(usdc.address)).to.equal( + mockStrategy2.address + ); + }); + it("Strategist can change reserve strategy", async () => { + await strategy + .connect(strategist) + .setReserveStrategy(usdc.address, mockStrategy2.address); + expect(await strategy.reserveStrategy(usdc.address)).to.equal( + mockStrategy2.address + ); + }); + it("Anyone else cannot change reserve strategy", async () => { + await expect( + strategy + .connect(operator) + .setReserveStrategy(usdc.address, mockStrategy2.address) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + }); + }); + }); + + describe("setMinDepositThreshold()", () => { + beforeEach(async () => { + _destructureFixture(await uniswapV3Fixture()); + }); + + describe("Permissions", () => { + it("Governer & Strategist can set the threshold", async () => { + await strategy + .connect(governor) + .setMinDepositThreshold(usdc.address, "1000"); + await strategy + .connect(strategist) + .setMinDepositThreshold(usdc.address, "2000"); + }); + it("Nobody else can change the threshold", async () => { + await expect( + strategy + .connect(operator) + .setMinDepositThreshold(usdc.address, "2000") + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + }); + }); + describe("Validations", () => { + it("Cannot call with invalid assets", async () => { + await expect( + strategy + .connect(governor) + .setMinDepositThreshold(dai.address, "2000") + ).to.be.revertedWith("Unsupported asset"); + }); + }); + }); + + describe("setRebalancePaused()", () => { + beforeEach(async () => { + _destructureFixture(await uniswapV3Fixture()); + }); + + it("Governer & Strategist can pause rebalance", async () => { + await strategy.connect(governor).setRebalancePaused(true); + expect(await strategy.rebalancePaused()).to.be.true; + await strategy.connect(strategist).setRebalancePaused(false); + expect(await strategy.rebalancePaused()).to.be.false; + }); + it("Nobody else can pause rebalance", async () => { + await expect( + strategy.connect(operator).setRebalancePaused(false) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + }); + }); + + describe("setSwapsPaused()", () => { + beforeEach(async () => { + _destructureFixture(await uniswapV3Fixture()); + }); + + it("Governer & Strategist can pause swaps", async () => { + await strategy.connect(governor).setSwapsPaused(true); + expect(await strategy.swapsPaused()).to.be.true; + await strategy.connect(strategist).setSwapsPaused(false); + expect(await strategy.swapsPaused()).to.be.false; + }); + it("Nobody else can pause swaps", async () => { + await expect( + strategy.connect(operator).setSwapsPaused(false) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + }); + }); + + describe("setMaxTVL()", async () => { + it("Should change max TVL", async () => { + const newVal = ousdUnits("12345"); + const tx = await strategy.connect(governor).setMaxTVL(newVal); + + await expect(tx).to.have.emittedEvent("MaxTVLChanged", [newVal]); + + expect(await strategy.maxTVL()).to.equal(newVal); + }); + + it("Should let Governor/Strategist set maxTVL", async () => { + for (const user of [governor, strategist]) { + await expect(strategy.connect(user).setMaxTVL(ousdUnits("12232"))).to + .not.be.reverted; + } + }); + + it("Should not let anyone else set maxTVL", async () => { + for (const user of [operator, matt]) { + await expect( + strategy.connect(user).setMaxTVL(ousdUnits("122332")) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + } + }); + }); + + describe("setMaxPositionValueLostThreshold()", () => { + it("Should change max loss threshold", async () => { + const newVal = ousdUnits("12345"); + const tx = await strategy + .connect(governor) + .setMaxPositionValueLostThreshold(newVal); + + await expect(tx).to.have.emittedEvent("MaxValueLostThresholdChanged", [ + newVal, + ]); + + expect(await strategy.maxPositionValueLostThreshold()).to.equal(newVal); + }); + + it("Should let Governor/Strategist set max loss threshold", async () => { + for (const user of [governor, strategist]) { + await expect( + strategy + .connect(user) + .setMaxPositionValueLostThreshold(ousdUnits("12232")) + ).to.not.be.reverted; + } + }); + + it("Should not let anyone else set the threshold", async () => { + for (const user of [operator, daniel]) { + await expect( + strategy + .connect(user) + .setMaxPositionValueLostThreshold(ousdUnits("122332")) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + } + }); + }); + + describe("resetLostValue()", () => { + it("Should let Governor reset the lost value ", async () => { + const tx = await strategy.connect(governor).resetLostValue(); + + await expect(tx).to.have.emittedEvent("NetLostValueReset", [ + governor.address, + ]); + + await expect(tx).to.have.emittedEvent("NetLostValueChanged", [0]); + }); + + it("Should not let anyone else set the threshold", async () => { + for (const user of [strategist, operator, franck]) { + await expect( + strategy.connect(user).resetLostValue() + ).to.be.revertedWith("Caller is not the Governor"); + } + }); + }); + + describe("setRebalancePriceThreshold()", () => { + it("Should change rebalance threshold", async () => { + const minTick = 111; + const maxTick = 222; + + const tx = await strategy + .connect(governor) + .setRebalancePriceThreshold(minTick, maxTick); + + await expect(tx).to.have.emittedEvent( + "RebalancePriceThresholdChanged", + [minTick, maxTick] + ); + + expect(await strategy.minRebalanceTick()).to.equal(minTick); + expect(await strategy.maxRebalanceTick()).to.equal(maxTick); + }); + + it("Should let Governor/Strategist set the threshold", async () => { + for (const user of [governor, strategist]) { + await expect( + strategy.connect(user).setRebalancePriceThreshold(111, 222) + ).to.not.be.reverted; + } + }); + + it("Should revert if tick indices are invalid", async () => { + await expect( + strategy.connect(governor).setRebalancePriceThreshold(222, 111) + ).to.be.revertedWith("Invalid threshold"); + }); + + it("Should not let anyone else set the threshold", async () => { + for (const user of [operator, josh]) { + await expect( + strategy.connect(user).setRebalancePriceThreshold(111, 222) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + } + }); + }); + + describe("setSwapPriceThreshold()", () => { + it("Should change swap threshold", async () => { + const minTick = 111; + const maxTick = 222; + const minTickX96 = await helper.getSqrtRatioAtTick(minTick); + const maxTickX96 = await helper.getSqrtRatioAtTick(maxTick); + + const tx = await strategy + .connect(governor) + .setSwapPriceThreshold(minTick, maxTick); + + await expect(tx).to.have.emittedEvent("SwapPriceThresholdChanged", [ + minTick, + minTickX96, + maxTick, + maxTickX96, + ]); + + expect(await strategy.minSwapPriceX96()).to.equal(minTickX96); + expect(await strategy.maxSwapPriceX96()).to.equal(maxTickX96); + }); + + it("Should let Governor/Strategist set the threshold", async () => { + for (const user of [governor, strategist]) { + await expect(strategy.connect(user).setSwapPriceThreshold(111, 222)) + .to.not.be.reverted; + } + }); + + it("Should revert if tick indices are invalid", async () => { + await expect( + strategy.connect(governor).setSwapPriceThreshold(222, 111) + ).to.be.revertedWith("Invalid threshold"); + }); + + it("Should not let anyone else set the threshold", async () => { + for (const user of [operator, josh]) { + await expect( + strategy.connect(user).setSwapPriceThreshold(111, 222) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + } + }); + }); + + describe("setLiquidityManagerImpl()", () => { + it("Should let Governor upgrade liquidity manager implementation ", async () => { + const tx = await strategy + .connect(governor) + .setLiquidityManagerImpl(mockStrategy2.address); + + await expect(tx).to.have.emittedEvent( + "LiquidityManagerImplementationUpgraded", + [mockStrategy2.address] + ); + }); + + it("Should throw if new implentation address is not a contract", async () => { + await expect( + strategy.connect(governor).setLiquidityManagerImpl(josh.address) + ).to.be.revertedWith("new implementation is not a contract"); + }); + + it("Should not let anyone else set the threshold", async () => { + for (const user of [strategist, operator, domen]) { + await expect( + strategy + .connect(user) + .setLiquidityManagerImpl(mockStrategy2.address) + ).to.be.revertedWith("Caller is not the Governor"); + } + }); + }); + }); + + describe("LiquidityManager", function () { + const mintLiquidity = async ({ + amount0, + amount1, + minAmount0, + minAmount1, + minRedeemAmount0, + minRedeemAmount1, + lowerTick, + upperTick, + existingPosition, + }) => { + const tx = await strategy + .connect(operator) + .rebalance( + usdcUnits(amount0), + usdtUnits(amount1), + usdcUnits(minAmount0 || "0"), + usdtUnits(minAmount1 || "0"), + usdcUnits(minRedeemAmount0 || "0"), + usdtUnits(minRedeemAmount1 || "0"), + lowerTick, + upperTick + ); + + if (!existingPosition) { + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + } + + await expect(tx).to.have.emittedEvent("UniswapV3LiquidityAdded"); + + return await tx.wait(); + }; + + const mintLiquidityBySwapping = async ({ + lowerTick, + upperTick, + amount0, + amount1, + minAmount0, + minAmount1, + minRedeemAmount0, + minRedeemAmount1, + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne, + existingPosition, + + signer, + expectRevert, + }) => { + const params = { + desiredAmount0: usdcUnits(amount0 || "10000"), + desiredAmount1: usdtUnits(amount1 || "10000"), + minAmount0: usdcUnits(minAmount0 || "0"), + minAmount1: usdtUnits(minAmount1 || "0"), + minRedeemAmount0: usdcUnits(minRedeemAmount0 || "0"), + minRedeemAmount1: usdtUnits(minRedeemAmount1 || "0"), + lowerTick, + upperTick, + swapAmountIn: BigNumber.from(swapAmountIn).mul(10 ** 6), + swapMinAmountOut: BigNumber.from(swapMinAmountOut).mul(10 ** 6), + sqrtPriceLimitX96, + swapZeroForOne, + }; + + if (expectRevert) { + if (typeof expectRevert === "string") { + await expect( + strategy.connect(signer || operator).swapAndRebalance(params) + ).to.be.revertedWith(expectRevert); + } else { + await expect( + strategy.connect(signer || operator).swapAndRebalance(params) + ).to.be.reverted; + } + + return; + } + + const tx = await strategy + .connect(signer || operator) + .swapAndRebalance(params); + + if (!existingPosition) { + await expect(tx).to.have.emittedEvent("UniswapV3PositionMinted"); + } + + await expect(tx).to.have.emittedEvent("UniswapV3LiquidityAdded"); + await expect(tx).to.have.emittedEvent("AssetSwappedForRebalancing", [ + swapZeroForOne ? usdc.address : usdt.address, + swapZeroForOne ? usdt.address : usdc.address, + usdcUnits(swapAmountIn), + (amountReceived) => + expect(amountReceived).to.be.gte( + usdcUnits(swapMinAmountOut), + "Swap amountOut mismatch" + ), + ]); + + return await tx.wait(); + }; + + describe("Rebalance/Mint", () => { + beforeEach(async () => { + _destructureFixture(await liquidityManagerFixture()); + }); + + it("Should mint a new position (with reserve funds)", async () => { + const { events } = await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-1000", + upperTick: "1000", + }); + + const [tokenId, amount0Deposited, amount1Deposited, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + // Check storage + const position = await strategy.tokenIdToPosition(tokenId); + expect(position.exists).to.be.true; + expect(position.liquidity).to.equal(liquidityMinted); + expect(position.netValue).to.equal( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + }); + + it("Should mint a new position below active tick", async () => { + const { events } = await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-1000", + upperTick: "-500", + }); + + const [tokenId, amount0Deposited, amount1Deposited, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + // Check storage + const position = await strategy.tokenIdToPosition(tokenId); + expect(position.exists).to.be.true; + expect(position.liquidity).to.equal(liquidityMinted); + expect(position.netValue).to.equal( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + }); + + it("Should mint a new position above active tick", async () => { + const { events } = await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "1000", + upperTick: "1500", + }); + + const [tokenId, amount0Deposited, amount1Deposited, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + // Check storage + const position = await strategy.tokenIdToPosition(tokenId); + expect(position.exists).to.be.true; + expect(position.liquidity).to.equal(liquidityMinted); + expect(position.netValue).to.equal( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + }); + + it("Should mint a new position (without reserve funds)", async () => { + // Transfer some tokens to strategy + const amount = await units("1000000", usdc); + await usdc.connect(josh).mint(amount); + await usdt.connect(josh).mint(amount); + await usdc.connect(josh).transfer(strategy.address, amount); + await usdt.connect(josh).transfer(strategy.address, amount); + + const reserve0Bal = await reserveStrategy.checkBalance(usdc.address); + const reserve1Bal = await reserveStrategy.checkBalance(usdt.address); + + // Mint + const { events } = await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-100", + upperTick: "100", + }); + + const [tokenId, amount0Deposited, amount1Deposited, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + // Check storage + const position = await strategy.tokenIdToPosition(tokenId); + expect(position.exists).to.be.true; + expect(position.liquidity).to.equal(liquidityMinted); + expect(position.netValue).to.equal( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + + // Should have deposited everything to reserve after mint + expect(await usdc.balanceOf(strategy.address)).to.equal(0); + expect(await reserveStrategy.checkBalance(usdc.address)).to.approxEqual( + reserve0Bal.add(amount).sub(amount0Deposited) + ); + expect(await usdt.balanceOf(strategy.address)).to.equal(0); + expect(await reserveStrategy.checkBalance(usdt.address)).to.approxEqual( + reserve1Bal.add(amount).sub(amount1Deposited) + ); + }); + + it("Should allow Governor/Strategist/Operator to rebalance", async () => { + for (const user of [governor, strategist, operator]) { + await strategy + .connect(user) + .rebalance("1", "1", "0", "0", "0", "0", "-1", "1"); + } + }); + it("Should revert if caller isn't Governor/Strategist/Operator", async () => { + await expect( + strategy + .connect(matt) + .rebalance("1", "1", "0", "0", "0", "0", "-1", "1") + ).to.be.revertedWith( + "Caller is not the Operator, Strategist or Governor" + ); + }); + + it("Should revert if rebalance is paused", async () => { + await strategy.connect(governor).setRebalancePaused(true); + await expect( + strategy + .connect(operator) + .rebalance("1", "1", "0", "0", "0", "0", "-1", "1") + ).to.be.revertedWith("Rebalances are paused"); + }); + + it("Should revert if out of rebalance limits", async () => { + await strategy.connect(governor).setRebalancePriceThreshold(-100, 200); + await expect( + strategy + .connect(operator) + .rebalance("1", "1", "0", "0", "0", "0", "-200", "1") + ).to.be.revertedWith("Rebalance position out of bounds"); + }); + + it("Should revert if TVL check fails", async () => { + await strategy.connect(governor).setMaxTVL(ousdUnits("100000")); + + await expect( + strategy + .connect(operator) + .rebalance( + usdcUnits("100000"), + usdtUnits("100000"), + "0", + "0", + "0", + "0", + "-200", + "1" + ) + ).to.be.revertedWith("MaxTVL threshold has been reached"); + }); + + it("Should revert if reserve funds aren't available", async () => { + const reserve0 = await reserveStrategy.checkBalance(usdt.address); + const reserve1 = await reserveStrategy.checkBalance(usdc.address); + + await expect( + strategy + .connect(operator) + .rebalance( + reserve0.mul(200), + reserve1.mul(321), + "0", + "0", + "0", + "0", + "-200", + "1" + ) + ).to.be.reverted; + }); + + it("Should revert if tick range is invalid", async () => { + await expect( + strategy + .connect(operator) + .rebalance( + usdcUnits("100000"), + usdtUnits("100000"), + "0", + "0", + "0", + "0", + "200", + "100" + ) + ).to.be.revertedWith("Invalid tick range"); + }); + }); + + describe("IncreaseLiquidity", () => { + beforeEach(async () => { + _destructureFixture(await activePositionFixture()); + }); + + it("Should increase liquidity w/ mint (if position already exists)", async () => { + const { events } = await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-500", + upperTick: "500", + }); + + const [tokenId, amount0Deposited, amount1Deposited, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + // Check storage + expect(await strategy.activeTokenId()).to.equal(tokenId); + const position = await strategy.tokenIdToPosition(tokenId); + expect(position.exists).to.be.true; + expect(position.liquidity).to.equal(liquidityMinted); + expect(position.netValue).to.equal( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + + // Call mintLiquidity again + await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-500", + upperTick: "500", + existingPosition: true, + }); + + // Check storage and ensure it has increased liquidity + expect(await strategy.activeTokenId()).to.equal(tokenId); + const newPosition = await strategy.tokenIdToPosition(tokenId); + expect(newPosition.liquidity).to.be.gt(liquidityMinted); + expect(newPosition.netValue).to.be.gt( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + }); + + it("Should increase liquidity of active position", async () => { + const { events } = await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-500", + upperTick: "500", + }); + + const [tokenId, amount0Deposited, amount1Deposited, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + // Check storage + expect(await strategy.activeTokenId()).to.equal(tokenId); + const position = await strategy.tokenIdToPosition(tokenId); + expect(position.exists).to.be.true; + expect(position.liquidity).to.equal(liquidityMinted); + expect(position.netValue).to.equal( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + + // Call increaseActivePositionLiquidity + await strategy + .connect(governor) + .increaseActivePositionLiquidity( + usdcUnits("100000"), + usdtUnits("100000"), + "0", + "0" + ); + + // Check storage and ensure it has increased liquidity + const newPosition = await strategy.tokenIdToPosition(tokenId); + expect(newPosition.liquidity).to.be.gt(liquidityMinted); + expect(newPosition.netValue).to.be.gt( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + }); + + it("Should revert if caller isn't Governor/Strategist/Operator", async () => { + await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-500", + upperTick: "500", + }); + + for (const user of [governor, strategist, operator]) { + await expect( + strategy + .connect(user) + .increaseActivePositionLiquidity("1", "1", "0", "0") + ).to.not.be.reverted; + } + + expect( + strategy + .connect(matt) + .increaseActivePositionLiquidity("1", "1", "0", "0") + ).to.be.revertedWith( + "Caller is not the Operator, Strategist or Governor" + ); + }); + + it("Should revert if no active position", async () => { + await strategy + .connect(operator) + .closePosition(await strategy.activeTokenId(), 0, 0); + await expect( + strategy + .connect(operator) + .increaseActivePositionLiquidity("1", "1", "0", "0") + ).to.be.revertedWith("No active position"); + }); + + it("Should revert if rebalance is paused", async () => { + await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-500", + upperTick: "500", + }); + + await strategy.connect(governor).setRebalancePaused(true); + await expect( + strategy + .connect(operator) + .increaseActivePositionLiquidity("1", "1", "0", "0") + ).to.be.revertedWith("Rebalances are paused"); + }); + + it("Should revert if reserve funds aren't available", async () => { + await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-500", + upperTick: "500", + }); + + const reserve0 = await reserveStrategy.checkBalance(usdt.address); + const reserve1 = await reserveStrategy.checkBalance(usdc.address); + + await expect( + strategy + .connect(operator) + .increaseActivePositionLiquidity( + reserve0.mul(200), + reserve1.mul(321), + "0", + "0" + ) + ).to.be.reverted; + }); + + it("Should revert if TVL check fails", async () => { + await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-500", + upperTick: "500", + }); + + await strategy.connect(governor).setMaxTVL(ousdUnits("100000")); + + await expect( + strategy + .connect(operator) + .increaseActivePositionLiquidity("1", "1", "0", "0") + ).to.be.revertedWith("MaxTVL threshold has been reached"); + }); + }); + + describe("DecreaseLiquidity/ClosePosition", () => { + beforeEach(async () => { + _destructureFixture(await activePositionFixture()); + }); + + it("Should close active position during a mint", async () => { + const tokenId = await strategy.activeTokenId(); + + // Mint position in a different tick range + await mintLiquidity({ + amount0: "100000", + amount1: "100000", + lowerTick: "-50", + upperTick: "5000", + }); + + expect(await strategy.activeTokenId()).to.not.equal(tokenId); + const lastPos = await strategy.tokenIdToPosition(tokenId); + expect(lastPos.liquidity).to.equal( + 0, + "Should've removed all liquidity from closed position" + ); + }); + + it("Should close position", async () => { + const tokenId = await strategy.activeTokenId(); + + await strategy.connect(operator).closePosition(tokenId, 0, 0); + expect(await strategy.activeTokenId()).to.not.equal(tokenId); + const storagePos = await strategy.tokenIdToPosition(tokenId); + expect(storagePos.liquidity).to.equal( + 0, + "Should've removed all liquidity from closed position" + ); + }); + + it("Should decrease liquidity of active position", async () => { + const tokenId = await strategy.activeTokenId(); + const lastPos = await strategy.tokenIdToPosition(tokenId); + + await strategy + .connect(operator) + .decreaseActivePositionLiquidity(lastPos.liquidity.div(4), "0", "0"); + + const newPos = await strategy.tokenIdToPosition(tokenId); + + expect(newPos.liquidity).to.be.lt(lastPos.liquidity); + expect(newPos.netValue).to.be.lt(lastPos.netValue); + }); + + it("Should liquidate active position during withdrawAll", async () => { + const tokenId = await strategy.activeTokenId(); + + await strategy + .connect(await impersonateAndFundContract(vault.address)) + .withdrawAll(); + + const pos = await strategy.tokenIdToPosition(tokenId); + + expect(pos.liquidity).to.equal(0); + expect(await strategy.activeTokenId()).to.equal(0); + expect(pos.netValue).to.equal(0); + }); + + it("Should revert if there's an active position during withdraw", async () => { + const impersonatedVaultSigner = await impersonateAndFundContract( + vault.address + ); + + const withdrawFuncSign = "withdraw(address,address,uint256)"; + + await expect( + // prettier-ignore + strategy.connect(impersonatedVaultSigner)[withdrawFuncSign](vault.address, usdc.address, "10") + ).to.be.revertedWith("Active position still open"); + }); + + it("Only vault can do withdraw/withdrawAll", async () => { + const impersonatedVaultSigner = await impersonateAndFundContract( + vault.address + ); + + await expect(strategy.connect(impersonatedVaultSigner).withdrawAll()).to + .not.be.reverted; + + for (const user of [governor, strategist, operator, daniel]) { + await expect(strategy.connect(user).withdrawAll()).to.be.revertedWith( + "Caller is not the Vault" + ); + } + }); + + it("Should let Governor/Strategist/Operator to decrease liquidity of positions", async () => { + for (const user of [governor, strategist, operator]) { + await expect( + strategy + .connect(user) + .decreaseActivePositionLiquidity("1000", "0", "0") + ).to.not.be.reverted; + } + }); + + it("Should revert if caller isn't Governor/Strategist/Operator", async () => { + await expect( + strategy + .connect(matt) + .decreaseActivePositionLiquidity("1000", "0", "0") + ).to.be.revertedWith( + "Caller is not the Operator, Strategist or Governor" + ); + }); + }); + + describe("Swap And Rebalance", () => { + let impersonatedVault; + beforeEach(async () => { + _destructureFixture(await liquidityManagerFixture()); + impersonatedVault = await impersonateAndFundContract(vault.address); + }); + + const drainFromReserve = async (asset) => { + const fSign = "withdraw(address,address,uint256)"; + // Move all out of reserve + // prettier-ignore + await reserveStrategy.connect(impersonatedVault)[fSign]( + vault.address, + asset.address, + await reserveStrategy.checkBalance(asset.address) + ); + }; + + it("Should swap token0 for token1 during rebalance", async () => { + // Move all USDT out of reserve + await drainFromReserve(usdt); + + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(2); + const swapZeroForOne = true; + + const { events } = await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne, + }); + + const [tokenId, amount0Deposited, amount1Deposited, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + // Check storage + expect(await strategy.activeTokenId()).to.equal(tokenId); + const position = await strategy.tokenIdToPosition(tokenId); + expect(position.exists).to.be.true; + expect(position.liquidity).to.equal(liquidityMinted); + expect(position.netValue).to.equal( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + }); + + it("Should swap token1 for token0 during rebalance", async () => { + // Move all USDC out of reserve + await drainFromReserve(usdc); + + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(-2); + const swapZeroForOne = false; + + const { events } = await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne, + }); + + const [tokenId, amount0Deposited, amount1Deposited, liquidityMinted] = + events.find((e) => e.event == "UniswapV3LiquidityAdded").args; + + // Check storage + expect(await strategy.activeTokenId()).to.equal(tokenId); + const position = await strategy.tokenIdToPosition(tokenId); + expect(position.exists).to.be.true; + expect(position.liquidity).to.equal(liquidityMinted); + expect(position.netValue).to.equal( + amount0Deposited.add(amount1Deposited).mul(1e12) + ); + }); + + it("Should revert if TVL check fails", async () => { + await drainFromReserve(usdt); + + await strategy.connect(governor).setMaxTVL(ousdUnits("1000")); + + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(2); + const swapZeroForOne = true; + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne, + + expectRevert: "MaxTVL threshold has been reached", + }); + }); + + it("Should revert if swap is paused", async () => { + await strategy.connect(governor).setSwapsPaused(true); + + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(2); + const swapZeroForOne = true; + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne, + + expectRevert: "Swaps are paused", + }); + }); + + it("Should revert if rebalance is paused", async () => { + await strategy.connect(governor).setRebalancePaused(true); + + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(2); + const swapZeroForOne = true; + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne, + + expectRevert: "Rebalances are paused", + }); + }); + + it("Should revert if swapping is unnecessary", async () => { + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(2); + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne: true, + + expectRevert: "Cannot swap when the asset is available in reserve", + }); + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne: false, + + expectRevert: "Cannot swap when the asset is available in reserve", + }); + }); + + it("Should revert if beyond swap limits", async () => { + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(2); + + await mockPool.setTick(-200000); + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne: true, + + expectRevert: "Price out of bounds", + }); + }); + + it("Should revert if pool is tilted", async () => { + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(-200000); + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "-100", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne: true, + + expectRevert: "Slippage out of bounds", + }); + }); + + it("Should revert if tick range is invalid", async () => { + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(2); + const swapZeroForOne = true; + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "1000", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne, + + expectRevert: "Invalid tick range", + }); + }); + + it("Should revert if caller isn't Governor/Strategist/Operator", async () => { + const amount = "100000"; + const swapAmountIn = "100000"; + const swapMinAmountOut = "100000"; + const sqrtPriceLimitX96 = await helper.getSqrtRatioAtTick(2); + const swapZeroForOne = true; + + await mintLiquidityBySwapping({ + amount0: amount, + amount1: amount, + lowerTick: "1000", + upperTick: "100", + swapAmountIn, + swapMinAmountOut, + sqrtPriceLimitX96, + swapZeroForOne, + + signer: domen, + expectRevert: "Caller is not the Operator, Strategist or Governor", + }); + }); + }); + + describe("Fees & Balances", () => { + beforeEach(async () => { + _destructureFixture(await activePositionFixture()); + }); + + it("Should include active position in balances", async () => { + const netBalance = (await strategy.checkBalance(usdc.address)).add( + await strategy.checkBalance(usdt.address) + ); + + expect(netBalance).to.approxEqualTolerance(usdcUnits("200000"), 3); + }); + + it("Should include pending fees in balances", async () => { + const balance0Before = await strategy.checkBalance(usdc.address); + const balance1Before = await strategy.checkBalance(usdt.address); + + // Set some fee + const expectedFee0 = usdcUnits("1234"); + const expectedFee1 = usdtUnits("5678"); + const tokenId = await strategy.activeTokenId(); + await mockPositionManager.setTokensOwed( + tokenId.toString(), + expectedFee0.toString(), + expectedFee1.toString() + ); + + expect(await strategy.checkBalance(usdc.address)).to.equal( + balance0Before.add(expectedFee0) + ); + + expect(await strategy.checkBalance(usdt.address)).to.equal( + balance1Before.add(expectedFee1) + ); + }); + + it("Should accrue and collect fees", async () => { + // No fee accrued yet + let fees = await strategy.getPendingFees(); + expect(fees[0]).to.equal(0, "No fee after mint"); + expect(fees[1]).to.equal(0, "No fee after mint"); + + const expectedFee0 = usdcUnits("1234"); + const expectedFee1 = usdtUnits("5678"); + const tokenId = await strategy.activeTokenId(); + await mockPositionManager.setTokensOwed( + tokenId.toString(), + expectedFee0.toString(), + expectedFee1.toString() + ); + + fees = await strategy.getPendingFees(); + expect(fees[0]).to.equal(expectedFee0, "Fee0 mismatch"); + expect(fees[1]).to.equal(expectedFee1, "Fee1 mismatch"); + + const tx = await strategy.connect(operator).collectFees(); + expect(tx).to.have.emittedEvent("UniswapV3FeeCollected", [ + tokenId, + expectedFee0, + expectedFee1, + ]); + }); + + it("Should allow Governor/Strategist/Operator to collect fees", async () => { + for (const user of [governor, strategist, operator]) { + await expect(strategy.connect(user).collectFees()).to.not.be.reverted; + } + }); + + it("Should revert if caller isn't Governor/Strategist/Operator", async () => { + await expect(strategy.connect(matt).collectFees()).to.be.revertedWith( + "Caller is not the Operator, Strategist or Governor" + ); + }); + }); + + describe("Net Value Lost Threshold", () => { + beforeEach(async () => { + _destructureFixture(await activePositionFixture()); + }); + + const _setNetLossVal = async (val) => { + const netLostValueStorageSlot = BigNumber.from(169).toHexString(); + const expectedVal = BigNumber.from(val); + + const byte32Val = expectedVal + .toHexString(val) + .replace( + "0x", + "0x" + + new Array(64 - (expectedVal.toHexString().length - 2)) + .fill("0") + .join("") + ); + await hre.network.provider.send("hardhat_setStorageAt", [ + strategy.address, + netLostValueStorageSlot, + // Set an higher lost value manually + byte32Val, + ]); + expect(await strategy.netLostValue()).to.equal( + expectedVal, + "Storage slot changed?" + ); + }; + + it("Should update gained value of position when rebalancing", async () => { + _setNetLossVal(ousdUnits("1000")); // $1000 + const tokenId = await strategy.activeTokenId(); + + const initialValue = (await strategy.checkBalance(usdc.address)) + .add(await strategy.checkBalance(usdt.address)) + .mul(1e12); + + // Increase value of that position (Hackish way) + for (const asset of [usdc, usdt]) { + const amount = "1000000"; + await asset.connect(domen).mint(await units(amount, asset)); + await asset + .connect(domen) + .approve(mockPositionManager.address, await units(amount, asset)); + } + await mockPositionManager.connect(domen).increaseLiquidity({ + tokenId, + amount0Desired: usdcUnits("30000"), + amount1Desired: usdtUnits("30000"), + amount0Min: 0, + amount1Min: 0, + deadline: 10000000, + }); + + const currentValue = (await strategy.checkBalance(usdc.address)) + .add(await strategy.checkBalance(usdt.address)) + .mul(1e12); + + expect(currentValue).to.be.gt(initialValue); + + // Increase liquidity to trigger net loss update + await mintLiquidity({ + amount0: "1", + amount1: "1", + lowerTick: "-5", + upperTick: "-2", + }); + + expect(await strategy.netLostValue()).to.equal(0); + }); + + it("Should update lost value of position when rebalancing", async () => { + const tokenId = await strategy.activeTokenId(); + + const initialValue = (await strategy.checkBalance(usdc.address)) + .add(await strategy.checkBalance(usdt.address)) + .mul(1e12); + + // Reduce value of that position (Hackish way) + const liquidity = (await mockPositionManager.positions(tokenId)) + .liquidity; + await mockPositionManager.connect(operator).decreaseLiquidity({ + tokenId, + liquidity: liquidity.div(4), + amount0Min: 0, + amount1Min: 0, + deadline: 10000000, + }); + + const currentValue = (await strategy.checkBalance(usdc.address)) + .add(await strategy.checkBalance(usdt.address)) + .mul(1e12); + + const valueLost = initialValue.sub(currentValue); + + // Increase liquidity to trigger net loss update + await strategy.increaseActivePositionLiquidity("1", "1", "0", "0"); + + expect(await strategy.netLostValue()).to.equal(valueLost); + }); + + it("Should update lost value when collecting fees", async () => { + await _setNetLossVal(ousdUnits("1000")); // $1000 + const tokenId = await strategy.activeTokenId(); + + await mockPositionManager.setTokensOwed( + tokenId, + usdcUnits("330"), // $330 + usdtUnits("120") // $120 + ); + await strategy.collectFees(); + + expect(await strategy.netLostValue()).to.equal( + BigNumber.from(ousdUnits("550")) + ); + }); + + it("Should reset lost value when collecting huge fees", async () => { + await _setNetLossVal(ousdUnits("1000")); // $1000 + const tokenId = await strategy.activeTokenId(); + + await mockPositionManager.setTokensOwed( + tokenId, + usdcUnits("4000"), // $4000 + usdtUnits("5000") // $5000 + ); + await strategy.collectFees(); + + expect(await strategy.netLostValue()).to.equal(BigNumber.from("0")); + }); + + it("Should allow close/withdraw beyond threshold", async () => { + await _setNetLossVal("999999999999999999999999999999999999999999999"); + + const tokenId = await strategy.activeTokenId(); + + await expect( + strategy.connect(operator).closePosition(tokenId, "0", "0") + ).to.not.be.reverted; + + expect(await strategy.activeTokenId()).to.not.equal(tokenId); + }); + + it("Should revert if beyond threshold (during mint)", async () => { + await _setNetLossVal("999999999999999999999999999999999999999999999"); + + await expect( + strategy + .connect(operator) + .rebalance("1", "1", "0", "0", "0", "0", "-120", "1234") + ).to.be.revertedWith("Over max value loss threshold"); + }); + + it("Should revert if beyond threshold (when increasing liquidity)", async () => { + await _setNetLossVal("999999999999999999999999999999999999999999999"); + + await expect( + strategy + .connect(operator) + .increaseActivePositionLiquidity("1", "1", "0", "0") + ).to.be.revertedWith("Over max value loss threshold"); + }); + }); + }); +}); diff --git a/contracts/test/vault/vault.fork-test.js b/contracts/test/vault/vault.fork-test.js index 9888e372be..57f2764759 100644 --- a/contracts/test/vault/vault.fork-test.js +++ b/contracts/test/vault/vault.fork-test.js @@ -306,6 +306,7 @@ forkOnlyDescribe("ForkTest: Vault", function () { "0x79F2188EF9350A1dC11A062cca0abE90684b0197", // MorphoAaveStrategy // TODO: Hard-code these after deploy //"0x7A192DD9Cc4Ea9bdEdeC9992df74F1DA55e60a19", // LUSD MetaStrategy + "0xF4632427B2877c4c12670B5c75F794BCe16281FA", // USDC<>USDT Uniswap V3 Strategy ]; for (const s of strategies) { @@ -332,6 +333,7 @@ forkOnlyDescribe("ForkTest: Vault", function () { "0x9c459eeb3FA179a40329b81C1635525e9A0Ef094", "0x5A4eEe58744D1430876d5cA93cAB5CcB763C037D", // Morpho "0x79F2188EF9350A1dC11A062cca0abE90684b0197", // MorphoAave + "0x050c4FcA28725d975c2896682eBD2905D2E58E84", // USDC<>USDT Uniswap V3 Strategy ]).to.include(await vault.assetDefaultStrategies(usdt.address)); }); @@ -344,6 +346,7 @@ forkOnlyDescribe("ForkTest: Vault", function () { "0x9c459eeb3FA179a40329b81C1635525e9A0Ef094", "0x5A4eEe58744D1430876d5cA93cAB5CcB763C037D", // Morpho "0x79F2188EF9350A1dC11A062cca0abE90684b0197", // MorphoAave + "0x050c4FcA28725d975c2896682eBD2905D2E58E84", // USDC<>USDT Uniswap V3 Strategy ]).to.include(await vault.assetDefaultStrategies(usdc.address)); }); @@ -356,6 +359,7 @@ forkOnlyDescribe("ForkTest: Vault", function () { "0x9c459eeb3FA179a40329b81C1635525e9A0Ef094", "0x5A4eEe58744D1430876d5cA93cAB5CcB763C037D", // Morpho "0x79F2188EF9350A1dC11A062cca0abE90684b0197", // MorphoAave + "0x050c4FcA28725d975c2896682eBD2905D2E58E84", // USDC<>USDT Uniswap V3 Strategy ]).to.include(await vault.assetDefaultStrategies(dai.address)); }); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index 130f4ffcb2..e8dfcce045 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -148,6 +148,13 @@ addresses.mainnet.Flipper = "0xcecaD69d7D4Ed6D52eFcFA028aF8732F27e08F70"; addresses.mainnet.Morpho = "0x8888882f8f843896699869179fB6E4f7e3B58888"; addresses.mainnet.MorphoLens = "0x930f1b46e1d081ec1524efd95752be3ece51ef67"; +addresses.mainnet.UniV3PositionManager = + "0xC36442b4a4522E871399CD717aBDD847Ab11FE88"; +addresses.mainnet.UniV3_USDC_USDT_Pool = + "0x3416cf6c708da44db2624d63ea0aaef7113527c6"; +addresses.mainnet.UniV3SwapRouter = + "0xE592427A0AEce92De3Edee1F18E0157C05861564"; + // OUSD Governance addresses.mainnet.GovernorFive = "0x3cdd07c16614059e66344a7b579dab4f9516c0b6"; addresses.mainnet.Timelock = "0x35918cDE7233F2dD33fA41ae3Cb6aE0e42E0e69F"; diff --git a/contracts/utils/deploy.js b/contracts/utils/deploy.js index 88d797a86b..d70dabd98b 100644 --- a/contracts/utils/deploy.js +++ b/contracts/utils/deploy.js @@ -15,6 +15,7 @@ const { getAssetAddresses, isSmokeTest, isForkTest, + isTest, } = require("../test/helpers.js"); const { diff --git a/contracts/yarn.lock b/contracts/yarn.lock index e9f470f90e..f8e56e6c94 100644 --- a/contracts/yarn.lock +++ b/contracts/yarn.lock @@ -1183,10 +1183,10 @@ "@types/sinon-chai" "^3.2.3" "@types/web3" "1.0.19" -"@openzeppelin/contracts@3.4.1-solc-0.7-2": - version "3.4.1-solc-0.7-2" - resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-3.4.1-solc-0.7-2.tgz#371c67ebffe50f551c3146a9eec5fe6ffe862e92" - integrity sha512-tAG9LWg8+M2CMu7hIsqHPaTyG4uDzjr6mhvH96LvOpLZZj6tgzTluBt+LsCf1/QaYrlis6pITvpIaIhE+iZB+Q== +"@openzeppelin/contracts@3.4.2-solc-0.7": + version "3.4.2-solc-0.7" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-3.4.2-solc-0.7.tgz#38f4dbab672631034076ccdf2f3201fab1726635" + integrity sha512-W6QmqgkADuFcTLzHL8vVoNBtkwjvQRpYIAom7KiUNoLKghyx3FgH0GBjt8NRvigV1ZmMOBllvE1By1C+bi8WpA== "@openzeppelin/contracts@4.4.2": version "4.4.2" @@ -1563,22 +1563,26 @@ resolved "https://registry.yarnpkg.com/@uniswap/v2-core/-/v2-core-1.0.1.tgz#af8f508bf183204779938969e2e54043e147d425" integrity sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q== -"@uniswap/v3-core@1.0.0", "@uniswap/v3-core@^1.0.0": +"@uniswap/v3-core@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@uniswap/v3-core/-/v3-core-1.0.0.tgz#6c24adacc4c25dceee0ba3ca142b35adbd7e359d" integrity sha512-kSC4djMGKMHj7sLMYVnn61k9nu+lHjMIxgg9CDQT+s2QYLoA56GbSK9Oxr+qJXzzygbkrmuY6cwgP6cW2JXPFA== -"@uniswap/v3-periphery@^1.1.1": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@uniswap/v3-periphery/-/v3-periphery-1.2.0.tgz#fbe1a4bd23234a5b7f3e13e6abb8c051cb5b2898" - integrity sha512-4rNXGwNmiXldFowr7pAmU26XxVJpWzRUHYy7MHsCFx47fHkcAay5NQ+o89YGitLMWx+vOqFO0QGl2Tw9+XX4WA== +"@uniswap/v3-core@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@uniswap/v3-core/-/v3-core-1.0.1.tgz#b6d2bdc6ba3c3fbd610bdc502395d86cd35264a0" + integrity sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ== + +"@uniswap/v3-periphery@^1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@uniswap/v3-periphery/-/v3-periphery-1.4.3.tgz#a6da4632dbd46b139cc13a410e4ec09ad22bd19f" + integrity sha512-80c+wtVzl5JJT8UQskxVYYG3oZb4pkhY0zDe0ab/RX4+8f9+W5d8wI4BT0wLB0wFQTSnbW+QdBSpkHA/vRyGBA== dependencies: - "@openzeppelin/contracts" "3.4.1-solc-0.7-2" + "@openzeppelin/contracts" "3.4.2-solc-0.7" "@uniswap/lib" "^4.0.1-alpha" "@uniswap/v2-core" "1.0.1" "@uniswap/v3-core" "1.0.0" base64-sol "1.0.1" - hardhat-watcher "^2.1.1" "@yarnpkg/lockfile@^1.1.0": version "1.1.0" @@ -3078,7 +3082,7 @@ chokidar@3.5.3: optionalDependencies: fsevents "~2.3.2" -chokidar@^3.4.0, chokidar@^3.4.3, chokidar@^3.5.2: +chokidar@^3.4.0, chokidar@^3.5.2: version "3.5.2" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ== @@ -5676,13 +5680,6 @@ hardhat-tracer@^2.2.2: chalk "^4.1.2" ethers "^5.6.1" -hardhat-watcher@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/hardhat-watcher/-/hardhat-watcher-2.1.1.tgz#8b05fec429ed45da11808bbf6054a90f3e34c51a" - integrity sha512-zilmvxAYD34IofBrwOliQn4z92UiDmt2c949DW4Gokf0vS0qk4YTfVCi/LmUBICThGygNANE3WfnRTpjCJGtDA== - dependencies: - chokidar "^3.4.3" - hardhat@^2.14.1: version "2.14.1" resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.14.1.tgz#4dd252717f4987d8221c4f6fd08233b7f4251fd8"