diff --git a/contracts/SmartVaultDeployerV4.sol b/contracts/SmartVaultDeployerV4.sol new file mode 100644 index 0000000..feca5e6 --- /dev/null +++ b/contracts/SmartVaultDeployerV4.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "contracts/SmartVaultV4.sol"; +import "contracts/PriceCalculator.sol"; +import "contracts/interfaces/ISmartVaultDeployer.sol"; + +contract SmartVaultDeployerV4 is ISmartVaultDeployer { + bytes32 private immutable NATIVE; + address private immutable priceCalculator; + + constructor(bytes32 _native, address _clEurUsd) { + NATIVE = _native; + priceCalculator = address(new PriceCalculator(_native, _clEurUsd)); + } + + function deploy(address _manager, address _owner, address _euros) external returns (address) { + return address(new SmartVaultV4(NATIVE, _manager, _owner, _euros, priceCalculator)); + } +} diff --git a/contracts/SmartVaultManagerV6.sol b/contracts/SmartVaultManagerV6.sol new file mode 100644 index 0000000..4f9a0d9 --- /dev/null +++ b/contracts/SmartVaultManagerV6.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "contracts/interfaces/INFTMetadataGenerator.sol"; +import "contracts/interfaces/IEUROs.sol"; +import "contracts/interfaces/ISmartVault.sol"; +import "contracts/interfaces/ISmartVaultDeployer.sol"; +import "contracts/interfaces/ISmartVaultIndex.sol"; +import "contracts/interfaces/ISmartVaultManager.sol"; +import "contracts/interfaces/ISmartVaultManagerV2.sol"; + +// +// TODO describe changes +// TODO upgraded zz/zz/zz +// +contract SmartVaultManagerV6 is ISmartVaultManager, ISmartVaultManagerV2, Initializable, ERC721Upgradeable, OwnableUpgradeable { + using SafeERC20 for IERC20; + + uint256 public constant HUNDRED_PC = 1e5; + + address public protocol; + address public liquidator; + address public euros; + uint256 public collateralRate; + address public tokenManager; + address public smartVaultDeployer; + ISmartVaultIndex private smartVaultIndex; + uint256 private lastToken; + address public nftMetadataGenerator; + uint256 public mintFeeRate; + uint256 public burnFeeRate; + uint256 public swapFeeRate; + address public weth; + address public swapRouter; + address public swapRouter2; + uint16 public userVaultLimit; + address public yieldManager; + + event VaultDeployed(address indexed vaultAddress, address indexed owner, address vaultType, uint256 tokenId); + event VaultLiquidated(address indexed vaultAddress); + event VaultTransferred(uint256 indexed tokenId, address from, address to); + + struct SmartVaultData { + uint256 tokenId; uint256 collateralRate; uint256 mintFeeRate; + uint256 burnFeeRate; ISmartVault.Status status; + } + + function initialize() initializer public {} + + modifier onlyLiquidator { + require(msg.sender == liquidator, "err-invalid-liquidator"); + _; + } + + function vaultIDs(address _holder) public view returns (uint256[] memory) { + return smartVaultIndex.getTokenIds(_holder); + } + + function vaultData(uint256 _tokenID) external view returns (SmartVaultData memory) { + return SmartVaultData({ + tokenId: _tokenID, + collateralRate: collateralRate, + mintFeeRate: mintFeeRate, + burnFeeRate: burnFeeRate, + status: ISmartVault(smartVaultIndex.getVaultAddress(_tokenID)).status() + }); + } + + function mint() external returns (address vault, uint256 tokenId) { + tokenId = lastToken + 1; + _safeMint(msg.sender, tokenId); + lastToken = tokenId; + vault = ISmartVaultDeployer(smartVaultDeployer).deploy(address(this), msg.sender, euros); + smartVaultIndex.addVaultAddress(tokenId, payable(vault)); + IEUROs(euros).grantRole(IEUROs(euros).MINTER_ROLE(), vault); + IEUROs(euros).grantRole(IEUROs(euros).BURNER_ROLE(), vault); + emit VaultDeployed(vault, msg.sender, euros, tokenId); + } + + function liquidateVault(uint256 _tokenId) external onlyLiquidator { + ISmartVault vault = ISmartVault(smartVaultIndex.getVaultAddress(_tokenId)); + try vault.undercollateralised() returns (bool _undercollateralised) { + require(_undercollateralised, "vault-not-undercollateralised"); + vault.liquidate(); + IEUROs(euros).revokeRole(IEUROs(euros).MINTER_ROLE(), address(vault)); + IEUROs(euros).revokeRole(IEUROs(euros).BURNER_ROLE(), address(vault)); + emit VaultLiquidated(address(vault)); + } catch { + revert("other-liquidation-error"); + } + } + + function tokenURI(uint256 _tokenId) public view virtual override returns (string memory) { + ISmartVault.Status memory vaultStatus = ISmartVault(smartVaultIndex.getVaultAddress(_tokenId)).status(); + return INFTMetadataGenerator(nftMetadataGenerator).generateNFTMetadata(_tokenId, vaultStatus); + } + + function totalSupply() external view returns (uint256) { + return lastToken; + } + + function setMintFeeRate(uint256 _rate) external onlyOwner { + mintFeeRate = _rate; + } + + function setBurnFeeRate(uint256 _rate) external onlyOwner { + burnFeeRate = _rate; + } + + function setSwapFeeRate(uint256 _rate) external onlyOwner { + swapFeeRate = _rate; + } + + function setWethAddress(address _weth) external onlyOwner() { + weth = _weth; + } + + function setSwapRouter2(address _swapRouter) external onlyOwner() { + swapRouter2 = _swapRouter; + } + + function setNFTMetadataGenerator(address _nftMetadataGenerator) external onlyOwner() { + nftMetadataGenerator = _nftMetadataGenerator; + } + + function setSmartVaultDeployer(address _smartVaultDeployer) external onlyOwner() { + smartVaultDeployer = _smartVaultDeployer; + } + + function setProtocolAddress(address _protocol) external onlyOwner() { + protocol = _protocol; + } + + function setLiquidatorAddress(address _liquidator) external onlyOwner() { + liquidator = _liquidator; + } + + function setUserVaultLimit(uint16 _userVaultLimit) external onlyOwner() { + userVaultLimit = _userVaultLimit; + } + + function setYieldManager(address _yieldManager) external onlyOwner() { + yieldManager = _yieldManager; + } + + // TODO test transfer + function _afterTokenTransfer(address _from, address _to, uint256 _tokenId, uint256) internal override { + require(vaultIDs(_to).length < userVaultLimit, "err-vault-limit"); + smartVaultIndex.transferTokenId(_from, _to, _tokenId); + if (address(_from) != address(0)) ISmartVault(smartVaultIndex.getVaultAddress(_tokenId)).setOwner(_to); + emit VaultTransferred(_tokenId, _from, _to); + } +} \ No newline at end of file diff --git a/contracts/SmartVaultV4.sol b/contracts/SmartVaultV4.sol new file mode 100644 index 0000000..cbdb6e9 --- /dev/null +++ b/contracts/SmartVaultV4.sol @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "contracts/interfaces/IEUROs.sol"; +import "contracts/interfaces/IHypervisor.sol"; +import "contracts/interfaces/IPriceCalculator.sol"; +import "contracts/interfaces/ISmartVault.sol"; +import "contracts/interfaces/ISmartVaultManagerV3.sol"; +import "contracts/interfaces/ISmartVaultYieldManager.sol"; +import "contracts/interfaces/ISwapRouter.sol"; +import "contracts/interfaces/ITokenManager.sol"; +import "contracts/interfaces/IWETH.sol"; + +import "hardhat/console.sol"; + +contract SmartVaultV4 is ISmartVault { + using SafeERC20 for IERC20; + + uint8 private constant version = 4; + bytes32 private constant vaultType = bytes32("EUROs"); + bytes32 private immutable NATIVE; + address public immutable manager; + IEUROs public immutable EUROs; + IPriceCalculator public immutable calculator; + address[] private Hypervisors; + + address public owner; + uint256 private minted; + bool private liquidated; + + event CollateralRemoved(bytes32 symbol, uint256 amount, address to); + event AssetRemoved(address token, uint256 amount, address to); + event EUROsMinted(address to, uint256 amount, uint256 fee); + event EUROsBurned(uint256 amount, uint256 fee); + + struct YieldPair { address hypervisor; address token0; uint256 amount0; address token1; uint256 amount1; } + error InvalidUser(); + error InvalidRequest(); + + constructor(bytes32 _native, address _manager, address _owner, address _euros, address _priceCalculator) { + NATIVE = _native; + owner = _owner; + manager = _manager; + EUROs = IEUROs(_euros); + calculator = IPriceCalculator(_priceCalculator); + } + + modifier onlyVaultManager { + if (msg.sender != manager) revert InvalidUser(); + _; + } + + modifier onlyOwner { + if (msg.sender != owner) revert InvalidUser(); + _; + } + + modifier ifMinted(uint256 _amount) { + if (minted < _amount) revert InvalidRequest(); + _; + } + + modifier ifNotLiquidated { + if (liquidated) revert InvalidRequest(); + _; + } + + function getTokenManager() private view returns (ITokenManager) { + return ITokenManager(ISmartVaultManagerV3(manager).tokenManager()); + } + + function yieldVaultCollateral(ITokenManager.Token[] memory _acceptedTokens) private view returns (uint256 _euros) { + for (uint256 i = 0; i < Hypervisors.length; i++) { + IHypervisor _Hypervisor = IHypervisor(Hypervisors[i]); + uint256 _balance = _Hypervisor.balanceOf(address(this)); + if (_balance > 0) { + uint256 _totalSupply = _Hypervisor.totalSupply(); + (uint256 _underlyingTotal0, uint256 _underlyingTotal1) = _Hypervisor.getTotalAmounts(); + address _token0 = _Hypervisor.token0(); + address _token1 = _Hypervisor.token1(); + uint256 _underlying0 = _balance * _underlyingTotal0 / _totalSupply; + uint256 _underlying1 = _balance * _underlyingTotal1 / _totalSupply; + if (_token0 == address(EUROs) || _token1 == address(EUROs)) { + // both EUROs and its vault pair are € stablecoins, but can be equivalent to €1 in collateral + _euros += _underlying0; + _euros += _underlying1; + } else { + for (uint256 j = 0; j < _acceptedTokens.length; j++) { + ITokenManager.Token memory _token = _acceptedTokens[j]; + if (_token.addr == _token0) _euros += calculator.tokenToEur(_token, _underlying0); + if (_token.addr == _token1) _euros += calculator.tokenToEur(_token, _underlying1); + } + } + } + } + } + + function euroCollateral() private view returns (uint256 euros) { + ITokenManager tokenManager = ITokenManager(ISmartVaultManagerV3(manager).tokenManager()); + ITokenManager.Token[] memory acceptedTokens = tokenManager.getAcceptedTokens(); + for (uint256 i = 0; i < acceptedTokens.length; i++) { + ITokenManager.Token memory _token = acceptedTokens[i]; + euros += calculator.tokenToEur(_token, getAssetBalance(_token.addr)); + } + + euros += yieldVaultCollateral(acceptedTokens); + } + + function maxMintable(uint256 _collateral) private view returns (uint256) { + return _collateral * ISmartVaultManagerV3(manager).HUNDRED_PC() / ISmartVaultManagerV3(manager).collateralRate(); + } + + function getAssetBalance(address _tokenAddress) private view returns (uint256 amount) { + return _tokenAddress == address(0) ? address(this).balance : IERC20(_tokenAddress).balanceOf(address(this)); + } + + function getAssets() private view returns (Asset[] memory) { + ITokenManager tokenManager = ITokenManager(ISmartVaultManagerV3(manager).tokenManager()); + ITokenManager.Token[] memory acceptedTokens = tokenManager.getAcceptedTokens(); + Asset[] memory assets = new Asset[](acceptedTokens.length); + for (uint256 i = 0; i < acceptedTokens.length; i++) { + ITokenManager.Token memory token = acceptedTokens[i]; + uint256 assetBalance = getAssetBalance(token.addr); + assets[i] = Asset(token, assetBalance, calculator.tokenToEur(token, assetBalance)); + } + return assets; + } + + function status() external view returns (Status memory) { + uint256 _collateral = euroCollateral(); + return Status(address(this), minted, maxMintable(_collateral), _collateral, + getAssets(), liquidated, version, vaultType); + } + + function undercollateralised() public view returns (bool) { + return minted > maxMintable(euroCollateral()); + } + + function liquidateNative() private { + if (address(this).balance != 0) { + (bool sent,) = payable(ISmartVaultManagerV3(manager).protocol()).call{value: address(this).balance}(""); + if (!sent) revert InvalidRequest(); + } + } + + function liquidateERC20(IERC20 _token) private { + if (_token.balanceOf(address(this)) != 0) _token.safeTransfer(ISmartVaultManagerV3(manager).protocol(), _token.balanceOf(address(this))); + } + + function liquidate() external onlyVaultManager { + if (!undercollateralised()) revert InvalidRequest(); + liquidated = true; + minted = 0; + liquidateNative(); + ITokenManager.Token[] memory tokens = ITokenManager(ISmartVaultManagerV3(manager).tokenManager()).getAcceptedTokens(); + for (uint256 i = 0; i < tokens.length; i++) { + if (tokens[i].symbol != NATIVE) liquidateERC20(IERC20(tokens[i].addr)); + } + } + + receive() external payable {} + + function canRemoveCollateral(ITokenManager.Token memory _token, uint256 _amount) private view returns (bool) { + if (minted == 0) return true; + uint256 eurValueToRemove = calculator.tokenToEur(_token, _amount); + uint256 _newCollateral = euroCollateral() - eurValueToRemove; + return maxMintable(_newCollateral) >= minted; + } + + function removeCollateralNative(uint256 _amount, address payable _to) external onlyOwner { + if (!canRemoveCollateral(getTokenManager().getToken(NATIVE), _amount)) revert InvalidRequest(); + (bool sent,) = _to.call{value: _amount}(""); + if (!sent) revert InvalidRequest(); + emit CollateralRemoved(NATIVE, _amount, _to); + } + + function removeCollateral(bytes32 _symbol, uint256 _amount, address _to) external onlyOwner { + ITokenManager.Token memory token = getTokenManager().getToken(_symbol); + if (!canRemoveCollateral(token, _amount)) revert InvalidRequest(); + IERC20(token.addr).safeTransfer(_to, _amount); + emit CollateralRemoved(_symbol, _amount, _to); + } + + function removeAsset(address _tokenAddr, uint256 _amount, address _to) external onlyOwner { + ITokenManager.Token memory token = getTokenManager().getTokenIfExists(_tokenAddr); + if (token.addr == _tokenAddr && !canRemoveCollateral(token, _amount)) revert InvalidRequest(); + IERC20(_tokenAddr).safeTransfer(_to, _amount); + emit AssetRemoved(_tokenAddr, _amount, _to); + } + + function fullyCollateralised(uint256 _amount) private view returns (bool) { + return minted + _amount <= maxMintable(euroCollateral()); + } + + function mint(address _to, uint256 _amount) external onlyOwner ifNotLiquidated { + uint256 fee = _amount * ISmartVaultManagerV3(manager).mintFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC(); + if (!fullyCollateralised(_amount + fee)) revert InvalidRequest(); + minted = minted + _amount + fee; + EUROs.mint(_to, _amount); + EUROs.mint(ISmartVaultManagerV3(manager).protocol(), fee); + emit EUROsMinted(_to, _amount, fee); + } + + function burn(uint256 _amount) external ifMinted(_amount) { + uint256 fee = _amount * ISmartVaultManagerV3(manager).burnFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC(); + minted = minted - _amount; + EUROs.burn(msg.sender, _amount + fee); + if (fee > 0) EUROs.mint(ISmartVaultManagerV3(manager).protocol(), fee); + emit EUROsBurned(_amount, fee); + } + + + function getToken(bytes32 _symbol) private view returns (ITokenManager.Token memory _token) { + ITokenManager.Token[] memory tokens = ITokenManager(ISmartVaultManagerV3(manager).tokenManager()).getAcceptedTokens(); + for (uint256 i = 0; i < tokens.length; i++) { + if (tokens[i].symbol == _symbol) _token = tokens[i]; + } + if (_token.symbol == bytes32(0)) revert InvalidRequest(); + } + + function getTokenisedAddr(bytes32 _symbol) private view returns (address) { + ITokenManager.Token memory _token = getToken(_symbol); + return _token.addr == address(0) ? ISmartVaultManagerV3(manager).weth() : _token.addr; + } + + function executeNativeSwapAndFee(ISwapRouter.ExactInputSingleParams memory _params, uint256 _swapFee) private { + (bool sent,) = payable(ISmartVaultManagerV3(manager).protocol()).call{value: _swapFee}(""); + if (!sent) revert InvalidRequest(); + ISwapRouter(ISmartVaultManagerV3(manager).swapRouter2()).exactInputSingle{value: _params.amountIn}(_params); + } + + function executeERC20SwapAndFee(ISwapRouter.ExactInputSingleParams memory _params, uint256 _swapFee) private { + IERC20(_params.tokenIn).safeTransfer(ISmartVaultManagerV3(manager).protocol(), _swapFee); + IERC20(_params.tokenIn).safeApprove(ISmartVaultManagerV3(manager).swapRouter2(), _params.amountIn); + ISwapRouter(ISmartVaultManagerV3(manager).swapRouter2()).exactInputSingle(_params); + IERC20(_params.tokenIn).safeApprove(ISmartVaultManagerV3(manager).swapRouter2(), 0); + IWETH weth = IWETH(ISmartVaultManagerV3(manager).weth()); + // convert potentially received weth to eth + uint256 wethBalance = weth.balanceOf(address(this)); + if (wethBalance > 0) weth.withdraw(wethBalance); + } + + function calculateMinimumAmountOut(bytes32 _inTokenSymbol, bytes32 _outTokenSymbol, uint256 _amount) private view returns (uint256) { + ISmartVaultManagerV3 _manager = ISmartVaultManagerV3(manager); + uint256 requiredCollateralValue = minted * _manager.collateralRate() / _manager.HUNDRED_PC(); + // add 1% min collateral buffer + uint256 collateralValueMinusSwapValue = euroCollateral() - calculator.tokenToEur(getToken(_inTokenSymbol), _amount * 101 / 100); + return collateralValueMinusSwapValue >= requiredCollateralValue ? + 0 : calculator.eurToToken(getToken(_outTokenSymbol), requiredCollateralValue - collateralValueMinusSwapValue); + } + + function swap(bytes32 _inToken, bytes32 _outToken, uint256 _amount, uint256 _requestedMinOut) external onlyOwner { + uint256 swapFee = _amount * ISmartVaultManagerV3(manager).swapFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC(); + address inToken = getTokenisedAddr(_inToken); + uint256 minimumAmountOut = calculateMinimumAmountOut(_inToken, _outToken, _amount + swapFee); + if (_requestedMinOut > minimumAmountOut) minimumAmountOut = _requestedMinOut; + ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ + tokenIn: inToken, + tokenOut: getTokenisedAddr(_outToken), + fee: 3000, + recipient: address(this), + deadline: block.timestamp + 60, + amountIn: _amount - swapFee, + amountOutMinimum: minimumAmountOut, + sqrtPriceLimitX96: 0 + }); + inToken == ISmartVaultManagerV3(manager).weth() ? + executeNativeSwapAndFee(params, swapFee) : + executeERC20SwapAndFee(params, swapFee); + } + + function addUniqueHypervisor(address _vault) private { + for (uint256 i = 0; i < Hypervisors.length; i++) { + if (Hypervisors[i] == _vault) return; + } + Hypervisors.push(_vault); + } + + function removeHypervisor(address _vault) private { + for (uint256 i = 0; i < Hypervisors.length; i++) { + if (Hypervisors[i] == _vault) { + Hypervisors[i] = Hypervisors[Hypervisors.length - 1]; + Hypervisors.pop(); + } + } + } + + function depositYield(bytes32 _symbol, uint256 _euroPercentage) external onlyOwner { + if (_symbol == NATIVE) IWETH(ISmartVaultManagerV3(manager).weth()).deposit{value: address(this).balance}(); + address _token = getTokenisedAddr(_symbol); + uint256 _balance = getAssetBalance(_token); + if (_balance == 0) revert InvalidRequest(); + IERC20(_token).safeApprove(ISmartVaultManagerV3(manager).yieldManager(), _balance); + (address _vault1, address _vault2) = ISmartVaultYieldManager(ISmartVaultManagerV3(manager).yieldManager()).deposit(_token, _euroPercentage); + addUniqueHypervisor(_vault1); + addUniqueHypervisor(_vault2); + if (undercollateralised()) revert InvalidRequest(); + } + + function withdrawYield(address _vault, bytes32 _symbol) external onlyOwner { + address _token = getTokenisedAddr(_symbol); + IERC20(_vault).safeApprove(ISmartVaultManagerV3(manager).yieldManager(), IERC20(_vault).balanceOf(address(this))); + ISmartVaultYieldManager(ISmartVaultManagerV3(manager).yieldManager()).withdraw(_vault, _token); + removeHypervisor(_vault); + if (_symbol == NATIVE) { + IWETH(_token).withdraw(getAssetBalance(_token)); + } + if (undercollateralised()) revert InvalidRequest(); + } + + function yieldAssets() external view returns (YieldPair[] memory _yieldPairs) { + _yieldPairs = new YieldPair[](Hypervisors.length); + for (uint256 i = 0; i < Hypervisors.length; i++) { + IHypervisor _Hypervisor = IHypervisor(Hypervisors[i]); + uint256 _balance = _Hypervisor.balanceOf(address(this)); + uint256 _vaultTotal = _Hypervisor.totalSupply(); + (uint256 _underlyingTotal0, uint256 _underlyingTotal1) = _Hypervisor.getTotalAmounts(); + + _yieldPairs[i].hypervisor = Hypervisors[i]; + _yieldPairs[i].token0 = _Hypervisor.token0(); + _yieldPairs[i].token1 = _Hypervisor.token1(); + _yieldPairs[i].amount0 = _balance * _underlyingTotal0 / _vaultTotal; + _yieldPairs[i].amount1 = _balance * _underlyingTotal1 / _vaultTotal; + } + } + + function setOwner(address _newOwner) external onlyVaultManager { + owner = _newOwner; + } +} diff --git a/contracts/SmartVaultYieldManager.sol b/contracts/SmartVaultYieldManager.sol new file mode 100644 index 0000000..2224abe --- /dev/null +++ b/contracts/SmartVaultYieldManager.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "contracts/interfaces/IHypervisor.sol"; +import "contracts/interfaces/ISmartVaultYieldManager.sol"; +import "contracts/interfaces/ISmartVaultManager.sol"; +import "contracts/interfaces/ISwapRouter.sol"; +import "contracts/interfaces/IUniProxy.sol"; +import "contracts/interfaces/IWETH.sol"; + +import "hardhat/console.sol"; + +contract SmartVaultYieldManager is ISmartVaultYieldManager, Ownable { + using SafeERC20 for IERC20; + + address private immutable EUROs; + address private immutable EURA; + address private immutable WETH; + address private immutable uniProxy; + address private immutable eurosRouter; + address private immutable euroHypervisor; + address private immutable uniswapRouter; + uint256 private constant HUNDRED_PC = 1e5; + // min 10% to euros pool + uint256 private constant MIN_EURO_PERCENTAGE = 1e4; + address private smartVaultManager; + uint256 public feeRate; + mapping(address => HypervisorData) private hypervisorData; + + struct HypervisorData { address hypervisor; uint24 poolFee; bytes pathToEURA; bytes pathFromEURA; } + + event Deposit(address indexed smartVault, address indexed token, uint256 amount, uint256 euroPercentage); + event Withdraw(address indexed smartVault, address indexed token, address hypervisor, uint256 amount); + error InvalidRequest(); + + constructor(address _EUROs, address _EURA, address _WETH, address _uniProxy, address _eurosRouter, address _euroHypervisor, address _uniswapRouter) { + EUROs = _EUROs; + EURA = _EURA; + WETH = _WETH; + uniProxy = _uniProxy; + eurosRouter = _eurosRouter; + euroHypervisor = _euroHypervisor; + uniswapRouter = _uniswapRouter; + } + + function _thisBalanceOf(address _token) private view returns (uint256) { + return IERC20(_token).balanceOf(address(this)); + } + + function _swapToRatio(address _tokenA, address _hypervisor, address _swapRouter, uint24 _fee) private { + address _tokenB = _tokenA == IHypervisor(_hypervisor).token0() ? + IHypervisor(_hypervisor).token1() : IHypervisor(_hypervisor).token0(); + uint256 _tokenBBalance = _thisBalanceOf(_tokenB); + (uint256 amountStart, uint256 amountEnd) = IUniProxy(uniProxy).getDepositAmount(_hypervisor, _tokenA, _thisBalanceOf(_tokenA)); + uint256 _divisor = 2; + bool _tokenBTooLarge; + while(_tokenBBalance < amountStart || _tokenBBalance > amountEnd) { + uint256 _midRatio = (amountStart + amountEnd) / 2; + if (_tokenBBalance < _midRatio) { + if (_tokenBTooLarge) { + _divisor++; + _tokenBTooLarge = false; + } + IERC20(_tokenA).safeApprove(_swapRouter, _thisBalanceOf(_tokenA)); + try ISwapRouter(_swapRouter).exactOutputSingle(ISwapRouter.ExactOutputSingleParams({ + tokenIn: _tokenA, + tokenOut: _tokenB, + fee: _fee, + recipient: address(this), + deadline: block.timestamp + 60, + amountOut: (_midRatio - _tokenBBalance) / _divisor, + amountInMaximum: _thisBalanceOf(_tokenA), + sqrtPriceLimitX96: 0 + })) returns (uint256) {} catch { + _divisor++; + } + IERC20(_tokenA).safeApprove(_swapRouter, 0); + } else { + if (!_tokenBTooLarge) { + _divisor++; + _tokenBTooLarge = true; + } + IERC20(_tokenB).safeApprove(_swapRouter, (_tokenBBalance - _midRatio) / _divisor); + try ISwapRouter(_swapRouter).exactInputSingle(ISwapRouter.ExactInputSingleParams({ + tokenIn: _tokenB, + tokenOut: _tokenA, + fee: _fee, + recipient: address(this), + deadline: block.timestamp + 60, + amountIn: (_tokenBBalance - _midRatio) / _divisor, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + })) returns (uint256) {} catch { + _divisor++; + } + IERC20(_tokenB).safeApprove(_swapRouter, 0); + } + _tokenBBalance = _thisBalanceOf(_tokenB); + (amountStart, amountEnd) = IUniProxy(uniProxy).getDepositAmount(_hypervisor, _tokenA, _thisBalanceOf(_tokenA)); + } + } + + function _swapToSingleAsset(address _hypervisor, address _wantedToken, address _swapRouter, uint24 _fee) private { + address _token0 = IHypervisor(_hypervisor).token0(); + address _unwantedToken = IHypervisor(_hypervisor).token0() == _wantedToken ? + IHypervisor(_hypervisor).token1() : + _token0; + uint256 _balance = _thisBalanceOf(_unwantedToken); + IERC20(_unwantedToken).safeApprove(_swapRouter, _balance); + ISwapRouter(_swapRouter).exactInputSingle(ISwapRouter.ExactInputSingleParams({ + tokenIn: _unwantedToken, + tokenOut: _wantedToken, + fee: _fee, + recipient: address(this), + deadline: block.timestamp + 60, + amountIn: _balance, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + })); + IERC20(_unwantedToken).safeApprove(_swapRouter, 0); + } + + function _swapToEURA(address _collateralToken, uint256 _euroPercentage, bytes memory _pathToEURA) private { + uint256 _euroYieldPortion = _thisBalanceOf(_collateralToken) * _euroPercentage / HUNDRED_PC; + IERC20(_collateralToken).safeApprove(uniswapRouter, _euroYieldPortion); + ISwapRouter(uniswapRouter).exactInput(ISwapRouter.ExactInputParams({ + path: _pathToEURA, + recipient: address(this), + deadline: block.timestamp + 60, + amountIn: _euroYieldPortion, + amountOutMinimum: 1 + })); + IERC20(_collateralToken).safeApprove(uniswapRouter, 0); + } + + function _deposit(address _hypervisor) private { + address _token0 = IHypervisor(_hypervisor).token0(); + address _token1 = IHypervisor(_hypervisor).token1(); + IERC20(_token0).safeApprove(_hypervisor, _thisBalanceOf(_token0)); + IERC20(_token1).safeApprove(_hypervisor, _thisBalanceOf(_token1)); + IUniProxy(uniProxy).deposit(_thisBalanceOf(_token0), _thisBalanceOf(_token1), msg.sender, _hypervisor, [uint256(0),uint256(0),uint256(0),uint256(0)]); + IERC20(_token0).safeApprove(_hypervisor, 0); + IERC20(_token1).safeApprove(_hypervisor, 0); + } + + function _euroDeposit(address _collateralToken, uint256 _euroPercentage, bytes memory _pathToEURA) private { + _swapToEURA(_collateralToken, _euroPercentage, _pathToEURA); + _swapToRatio(EURA, euroHypervisor, eurosRouter, 500); + _deposit(euroHypervisor); + } + + function _otherDeposit(address _collateralToken, HypervisorData memory _hypervisorData) private { + _swapToRatio(_collateralToken, _hypervisorData.hypervisor, uniswapRouter, _hypervisorData.poolFee); + _deposit(_hypervisorData.hypervisor); + } + + function deposit(address _collateralToken, uint256 _euroPercentage) external returns (address _hypervisor0, address _hypervisor1) { + if (_euroPercentage < MIN_EURO_PERCENTAGE) revert InvalidRequest(); + uint256 _balance = IERC20(_collateralToken).balanceOf(address(msg.sender)); + IERC20(_collateralToken).safeTransferFrom(msg.sender, address(this), _balance); + HypervisorData memory _hypervisorData = hypervisorData[_collateralToken]; + if (_hypervisorData.hypervisor == address(0)) revert InvalidRequest(); + _euroDeposit(_collateralToken, _euroPercentage, _hypervisorData.pathToEURA); + _otherDeposit(_collateralToken, _hypervisorData); + emit Deposit(msg.sender, _collateralToken, _balance, _euroPercentage); + return (euroHypervisor, _hypervisorData.hypervisor); + } + + function _sellEURA(address _token) private { + bytes memory _pathFromEURA = hypervisorData[_token].pathFromEURA; + uint256 _balance = _thisBalanceOf(EURA); + IERC20(EURA).safeApprove(uniswapRouter, _balance); + ISwapRouter(uniswapRouter).exactInput(ISwapRouter.ExactInputParams({ + path: _pathFromEURA, + recipient: address(this), + deadline: block.timestamp + 60, + amountIn: _balance, + amountOutMinimum: 0 + })); + IERC20(EUROs).safeApprove(uniswapRouter, 0); + } + + function _withdrawEUROsDeposit(address _hypervisor, address _token) private { + IHypervisor(_hypervisor).withdraw(_thisBalanceOf(_hypervisor), address(this), address(this), [uint256(0),uint256(0),uint256(0),uint256(0)]); + _swapToSingleAsset(euroHypervisor, EURA, eurosRouter, 500); + _sellEURA(_token); + } + + function _withdrawOtherDeposit(address _hypervisor, address _token) private { + HypervisorData memory _hypervisorData = hypervisorData[_token]; + if (_hypervisorData.hypervisor != _hypervisor) revert InvalidRequest(); + IHypervisor(_hypervisor).withdraw(_thisBalanceOf(_hypervisor), address(this), address(this), [uint256(0),uint256(0),uint256(0),uint256(0)]); + _swapToSingleAsset(_hypervisor, _token, uniswapRouter, _hypervisorData.poolFee); + } + + function withdraw(address _hypervisor, address _token) external { + IERC20(_hypervisor).safeTransferFrom(msg.sender, address(this), IERC20(_hypervisor).balanceOf(msg.sender)); + _hypervisor == euroHypervisor ? + _withdrawEUROsDeposit(_hypervisor, _token) : + _withdrawOtherDeposit(_hypervisor, _token); + uint256 _withdrawn = _thisBalanceOf(_token); + uint256 _fee = _withdrawn * feeRate / HUNDRED_PC; + _withdrawn = _withdrawn - _fee; + IERC20(_token).safeTransfer(ISmartVaultManager(smartVaultManager).protocol(), _fee); + IERC20(_token).safeTransfer(msg.sender, _withdrawn); + emit Withdraw(msg.sender, _token, _hypervisor, _withdrawn); + } + + function addHypervisorData(address _collateralToken, address _hypervisor, uint24 _poolFee, bytes memory _pathToEURA, bytes memory _pathFromEURA) external onlyOwner { + hypervisorData[_collateralToken] = HypervisorData(_hypervisor, _poolFee, _pathToEURA, _pathFromEURA); + } + + function removeHypervisorData(address _collateralToken) external onlyOwner { + delete hypervisorData[_collateralToken]; + } + + function setFeeData(uint256 _feeRate, address _smartVaultManager) external onlyOwner { + feeRate = _feeRate; + smartVaultManager = _smartVaultManager; + } +} diff --git a/contracts/interfaces/IHypervisor.sol b/contracts/interfaces/IHypervisor.sol new file mode 100644 index 0000000..21c2fd0 --- /dev/null +++ b/contracts/interfaces/IHypervisor.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IHypervisor is IERC20 { + function token0() external view returns (address); + function token1() external view returns (address); + function getTotalAmounts() external view returns (uint256 total0, uint256 total1); + function deposit( + uint256 deposit0, + uint256 deposit1, + address to, + address from, + uint256[4] memory inMin + ) external returns (uint256 shares); + + function withdraw( + uint256 shares, + address to, + address from, + uint256[4] memory minAmounts + ) external returns (uint256 amount0, uint256 amount1); +} \ No newline at end of file diff --git a/contracts/interfaces/ISmartVaultManagerV3.sol b/contracts/interfaces/ISmartVaultManagerV3.sol index 1942fbf..383c650 100644 --- a/contracts/interfaces/ISmartVaultManagerV3.sol +++ b/contracts/interfaces/ISmartVaultManagerV3.sol @@ -6,4 +6,5 @@ import "contracts/interfaces/ISmartVaultManagerV2.sol"; interface ISmartVaultManagerV3 is ISmartVaultManagerV2, ISmartVaultManager { function swapRouter2() external view returns (address); + function yieldManager() external view returns (address); } \ No newline at end of file diff --git a/contracts/interfaces/ISmartVaultYieldManager.sol b/contracts/interfaces/ISmartVaultYieldManager.sol new file mode 100644 index 0000000..4ead18f --- /dev/null +++ b/contracts/interfaces/ISmartVaultYieldManager.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +interface ISmartVaultYieldManager { + function deposit(address _collateralToken, uint256 _euroPercentage) external returns (address vault0, address vault1); + function withdraw(address _vault, address _token) external; +} \ No newline at end of file diff --git a/contracts/interfaces/ISwapRouter.sol b/contracts/interfaces/ISwapRouter.sol index cd6d2c1..bca6e1c 100644 --- a/contracts/interfaces/ISwapRouter.sol +++ b/contracts/interfaces/ISwapRouter.sol @@ -12,6 +12,39 @@ interface ISwapRouter { uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; } + + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } + + struct ExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + uint160 sqrtPriceLimitX96; + } + + struct ExactOutputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + } function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); + + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); + + function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 amountIn); + + function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 amountIn); } diff --git a/contracts/interfaces/IUniProxy.sol b/contracts/interfaces/IUniProxy.sol new file mode 100644 index 0000000..8ef6cf2 --- /dev/null +++ b/contracts/interfaces/IUniProxy.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +interface IUniProxy { + function getDepositAmount(address pos, address token, uint256 _deposit) external view returns (uint256 amountStart, uint256 amountEnd); + function deposit(uint256 deposit0, uint256 deposit1, address to, address pos, uint256[4] memory minIn) external returns (uint256 shares); +} \ No newline at end of file diff --git a/contracts/interfaces/IWETH.sol b/contracts/interfaces/IWETH.sol index 8db5bcb..b800463 100644 --- a/contracts/interfaces/IWETH.sol +++ b/contracts/interfaces/IWETH.sol @@ -4,5 +4,6 @@ pragma solidity 0.8.17; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IWETH is IERC20 { + function deposit() external payable; function withdraw(uint256) external; } \ No newline at end of file diff --git a/contracts/test_utils/Hypervisor.sol b/contracts/test_utils/Hypervisor.sol new file mode 100644 index 0000000..066ebe4 --- /dev/null +++ b/contracts/test_utils/Hypervisor.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "contracts/interfaces/IHypervisor.sol"; + +import "hardhat/console.sol"; + +contract HypervisorMock is IHypervisor, ERC20 { + address public immutable token0; + address public immutable token1; + + constructor (string memory _name, string memory _symbol, address _token0, address _token1) ERC20(_name, _symbol) { + token0 = _token0; + token1 = _token1; + } + + function getTotalAmounts() public view returns (uint256 total0, uint256 total1) { + total0 = IERC20(token0).balanceOf(address(this)); + total1 = IERC20(token1).balanceOf(address(this)); + } + + function deposit( + uint256 deposit0, + uint256 deposit1, + address to, + address from, + uint256[4] memory inMin + ) external returns (uint256 shares) { + IERC20(token0).transferFrom(from, address(this), deposit0); + IERC20(token1).transferFrom(from, address(this), deposit1); + // simplified calculation because our mock will not deal with a changing swap rate + _mint(to, deposit0); + } + + function withdraw( + uint256 shares, + address to, + address from, + uint256[4] memory minAmounts + ) external returns (uint256 amount0, uint256 amount1) { + (uint256 _total0, uint256 _total1) = getTotalAmounts(); + amount0 = shares * _total0 / totalSupply(); + amount1 = shares * _total1 / totalSupply(); + _burn(from, shares); + IERC20(token0).transfer(to, amount0); + IERC20(token1).transfer(to, amount1); + } +} \ No newline at end of file diff --git a/contracts/test_utils/MockSwapRouter.sol b/contracts/test_utils/MockSwapRouter.sol index 5864829..d2dcc0e 100644 --- a/contracts/test_utils/MockSwapRouter.sol +++ b/contracts/test_utils/MockSwapRouter.sol @@ -1,8 +1,11 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.17; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "contracts/interfaces/ISwapRouter.sol"; +import "hardhat/console.sol"; + contract MockSwapRouter is ISwapRouter { address private tokenIn; address private tokenOut; @@ -14,12 +17,14 @@ contract MockSwapRouter is ISwapRouter { uint160 private sqrtPriceLimitX96; uint256 private txValue; + mapping(address => mapping(address => uint256)) private rates; + struct MockSwapData { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 deadline; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; uint256 txValue; } - function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut) { + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 _amountOut) { tokenIn = params.tokenIn; tokenOut = params.tokenOut; fee = params.fee; @@ -29,6 +34,13 @@ contract MockSwapRouter is ISwapRouter { amountOutMinimum = params.amountOutMinimum; sqrtPriceLimitX96 = params.sqrtPriceLimitX96; txValue = msg.value; + + _amountOut = rates[tokenIn][tokenOut] * amountIn / 1e18; + require(_amountOut > amountOutMinimum); + if (msg.value == 0) { + IERC20(tokenIn).transferFrom(msg.sender, address(this), params.amountIn); + } + IERC20(tokenOut).transfer(recipient, _amountOut); } function receivedSwap() external view returns (MockSwapData memory) { @@ -37,4 +49,37 @@ contract MockSwapRouter is ISwapRouter { sqrtPriceLimitX96, txValue ); } + + function exactInput(ExactInputParams calldata params) external payable returns (uint256 _amountOut) { + (address _tokenIn,, address _tokenOut) = abi.decode(params.path, (address, uint24, address)); + _amountOut = rates[_tokenIn][_tokenOut] * params.amountIn / 1e18; + require(_amountOut > params.amountOutMinimum); + if (msg.value == 0) { + IERC20(_tokenIn).transferFrom(msg.sender, address(this), params.amountIn); + } + IERC20(_tokenOut).transfer(params.recipient, _amountOut); + } + + function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 _amountIn) { + (address _tokenOut, , address _tokenIn) = abi.decode(params.path, (address, uint24, address)); + _amountIn = params.amountOut * 1e18 / rates[_tokenIn][_tokenOut]; + require(_amountIn < params.amountInMaximum); + if (msg.value == 0) { + IERC20(_tokenIn).transferFrom(msg.sender, address(this), _amountIn); + } + IERC20(_tokenOut).transfer(params.recipient, params.amountOut); + } + + function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 _amountIn) { + _amountIn = params.amountOut * 1e18 / rates[params.tokenIn][params.tokenOut]; + require(_amountIn < params.amountInMaximum); + if (msg.value == 0) { + IERC20(params.tokenIn).transferFrom(msg.sender, address(this), _amountIn); + } + IERC20(params.tokenOut).transfer(params.recipient, params.amountOut); + } + + function setRate(address _tokenIn, address _tokenOut, uint256 _rate) external { + rates[_tokenIn][_tokenOut] = _rate; + } } \ No newline at end of file diff --git a/contracts/test_utils/MockWETH.sol b/contracts/test_utils/MockWETH.sol index e68ac40..6721d73 100644 --- a/contracts/test_utils/MockWETH.sol +++ b/contracts/test_utils/MockWETH.sol @@ -1,14 +1,23 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.17; -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "contracts/test_utils/ERC20Mock.sol"; import "contracts/interfaces/IWETH.sol"; -contract MockWETH is IWETH, ERC20 { +contract MockWETH is IWETH, ERC20Mock { - constructor() ERC20("Wrapped Ether", "WETH") { + constructor() ERC20Mock("Wrapped Ether", "WETH", 18) { } - function withdraw(uint256) external { + receive() external payable {} + + function withdraw(uint256 _value) external { + _burn(msg.sender, _value); + (bool sent, ) = payable(msg.sender).call{value: _value}(""); + require(sent); + } + + function deposit() external payable { + _mint(msg.sender, msg.value); } } \ No newline at end of file diff --git a/contracts/test_utils/UniProxyMock.sol b/contracts/test_utils/UniProxyMock.sol new file mode 100644 index 0000000..9664731 --- /dev/null +++ b/contracts/test_utils/UniProxyMock.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "contracts/interfaces/IHypervisor.sol"; +import "contracts/interfaces/IUniProxy.sol"; + +contract UniProxyMock is IUniProxy { + mapping(address => mapping(address => uint256)) private ratios; + + function getDepositAmount(address vault, address token, uint256 _deposit) external view returns (uint256 amountStart, uint256 amountEnd) { + uint256 _mid = ratios[vault][token] * _deposit / 1e18; + return (_mid * 999 / 1000, _mid * 1001 / 1000); + } + + function deposit(uint256 deposit0, uint256 deposit1, address to, address vault, uint256[4] memory minIn) external returns (uint256 shares) { + IHypervisor(vault).deposit(deposit0, deposit1, to, msg.sender, minIn); + } + + function setRatio(address _vault, address _inToken, uint256 _ratio) external { + ratios[_vault][_inToken] = _ratio; + } +} \ No newline at end of file diff --git a/contracts/SmartVaultDeployerV3.sol b/contracts/versions/SmartVaultDeployerV3.sol similarity index 93% rename from contracts/SmartVaultDeployerV3.sol rename to contracts/versions/SmartVaultDeployerV3.sol index 77251f8..cdb3b3c 100644 --- a/contracts/SmartVaultDeployerV3.sol +++ b/contracts/versions/SmartVaultDeployerV3.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.17; -import "contracts/SmartVaultV3.sol"; +import "contracts/versions/SmartVaultV3.sol"; import "contracts/PriceCalculator.sol"; import "contracts/interfaces/ISmartVaultDeployer.sol"; diff --git a/contracts/versions/SmartVaultManagerV4.sol b/contracts/versions/SmartVaultManagerV4.sol index a6d8261..7e507ec 100644 --- a/contracts/versions/SmartVaultManagerV4.sol +++ b/contracts/versions/SmartVaultManagerV4.sol @@ -67,13 +67,13 @@ contract SmartVaultManagerV4 is ISmartVaultManager, ISmartVaultManagerV2, Initia _; } - function vaults() external view returns (SmartVaultData[] memory) { + function vaults() external view returns (SmartVaultData[] memory _vaultData) { uint256[] memory tokenIds = smartVaultIndex.getTokenIds(msg.sender); uint256 idsLength = tokenIds.length; - SmartVaultData[] memory vaultData = new SmartVaultData[](idsLength); + _vaultData = new SmartVaultData[](idsLength); for (uint256 i = 0; i < idsLength; i++) { uint256 tokenId = tokenIds[i]; - vaultData[i] = SmartVaultData({ + _vaultData[i] = SmartVaultData({ tokenId: tokenId, collateralRate: collateralRate, mintFeeRate: mintFeeRate, @@ -81,7 +81,6 @@ contract SmartVaultManagerV4 is ISmartVaultManager, ISmartVaultManagerV2, Initia status: ISmartVault(smartVaultIndex.getVaultAddress(tokenId)).status() }); } - return vaultData; } function vaultIDs(address _holder) external view returns (uint256[] memory) { diff --git a/contracts/SmartVaultManagerV5.sol b/contracts/versions/SmartVaultManagerV5.sol similarity index 99% rename from contracts/SmartVaultManagerV5.sol rename to contracts/versions/SmartVaultManagerV5.sol index 7b149bd..c105054 100644 --- a/contracts/SmartVaultManagerV5.sol +++ b/contracts/versions/SmartVaultManagerV5.sol @@ -16,7 +16,7 @@ import "contracts/interfaces/ISmartVaultManagerV2.sol"; // // allows use of different swap router address (post 7/11 attack) // allows setting of protocol wallet address + liquidator address -// upgraded zz/zz/zz +// upgraded 22/02/24 // contract SmartVaultManagerV5 is ISmartVaultManager, ISmartVaultManagerV2, Initializable, ERC721Upgradeable, OwnableUpgradeable { using SafeERC20 for IERC20; diff --git a/contracts/SmartVaultV3.sol b/contracts/versions/SmartVaultV3.sol similarity index 100% rename from contracts/SmartVaultV3.sol rename to contracts/versions/SmartVaultV3.sol diff --git a/hardhat.config.js b/hardhat.config.js index 1c00cb6..c72eca4 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -13,7 +13,16 @@ const { const testAccounts = TEST_ACCOUNT_PRIVATE_KEY ? [TEST_ACCOUNT_PRIVATE_KEY] : []; module.exports = { - solidity: "0.8.17", + solidity: { + version: "0.8.17", + settings: { + viaIR: true, + optimizer: { + enabled: true, + runs: 1, + }, + }, + }, defaultNetwork: 'hardhat', networks: { mainnet: { diff --git a/scripts/liquidate.js b/scripts/liquidate.js new file mode 100644 index 0000000..064af0a --- /dev/null +++ b/scripts/liquidate.js @@ -0,0 +1,13 @@ +const { ethers } = require("hardhat"); +// const abi = '[{"inputs":[{"internalType":"address","name":"_clearance","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"clearance","outputs":[{"internalType":"contract IClearing","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"deposit0","type":"uint256"},{"internalType":"uint256","name":"deposit1","type":"uint256"},{"internalType":"address","name":"to","type":"address"},{"internalType":"address","name":"pos","type":"address"},{"internalType":"uint256[4]","name":"minIn","type":"uint256[4]"}],"name":"deposit","outputs":[{"internalType":"uint256","name":"shares","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"pos","type":"address"},{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"_deposit","type":"uint256"}],"name":"getDepositAmount","outputs":[{"internalType":"uint256","name":"amountStart","type":"uint256"},{"internalType":"uint256","name":"amountEnd","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"newClearance","type":"address"}],"name":"transferClearance","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"}]' +const abi = '[{"inputs":[{"internalType":"address","name":"_staking","type":"address"},{"internalType":"address","name":"_euros","type":"address"},{"internalType":"address","name":"_tokenManager","type":"address"},{"internalType":"address","name":"_smartVaultManager","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"AccessControlBadConfirmation","type":"error"},{"inputs":[{"internalType":"address","name":"account","type":"address"},{"internalType":"bytes32","name":"neededRole","type":"bytes32"}],"name":"AccessControlUnauthorizedAccount","type":"error"},{"inputs":[{"internalType":"address","name":"target","type":"address"}],"name":"AddressEmptyCode","type":"error"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"AddressInsufficientBalance","type":"error"},{"inputs":[],"name":"FailedInnerCall","type":"error"},{"inputs":[{"internalType":"address","name":"token","type":"address"}],"name":"SafeERC20FailedOperation","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"role","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"previousAdminRole","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"newAdminRole","type":"bytes32"}],"name":"RoleAdminChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"role","type":"bytes32"},{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":true,"internalType":"address","name":"sender","type":"address"}],"name":"RoleGranted","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"role","type":"bytes32"},{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":true,"internalType":"address","name":"sender","type":"address"}],"name":"RoleRevoked","type":"event"},{"inputs":[],"name":"DEFAULT_ADMIN_ROLE","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_token","type":"address"},{"internalType":"uint256","name":"_amount","type":"uint256"}],"name":"airdropToken","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"dropFees","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"}],"name":"getRoleAdmin","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"grantRole","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"hasRole","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"_tokenID","type":"uint256"}],"name":"liquidateVault","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"callerConfirmation","type":"address"}],"name":"renounceRole","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"revokeRole","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"stateMutability":"payable","type":"receive"}]' + +async function main() { + const manager = await ethers.getContractAt('SmartVaultManagerV5', '0xba169cceCCF7aC51dA223e04654Cf16ef41A68CC'); + console.log(await manager.estimateGas.vaultData(202)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/scripts/ratio.js b/scripts/ratio.js new file mode 100644 index 0000000..2643e38 --- /dev/null +++ b/scripts/ratio.js @@ -0,0 +1,59 @@ +const { ethers } = require("hardhat"); +const abi = '[{"inputs":[{"internalType":"address","name":"_clearance","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"clearance","outputs":[{"internalType":"contract IClearing","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"deposit0","type":"uint256"},{"internalType":"uint256","name":"deposit1","type":"uint256"},{"internalType":"address","name":"to","type":"address"},{"internalType":"address","name":"pos","type":"address"},{"internalType":"uint256[4]","name":"minIn","type":"uint256[4]"}],"name":"deposit","outputs":[{"internalType":"uint256","name":"shares","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"pos","type":"address"},{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"_deposit","type":"uint256"}],"name":"getDepositAmount","outputs":[{"internalType":"uint256","name":"amountStart","type":"uint256"},{"internalType":"uint256","name":"amountEnd","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"newClearance","type":"address"}],"name":"transferClearance","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"}]' + +async function main() { + const WETH = await ethers.getContractAt('IWETH', '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1'); + const WBTC = await ethers.getContractAt('IERC20', '0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f'); + const uniproxy = await ethers.getContractAt(JSON.parse(abi), '0x82FcEB07a4D01051519663f6c1c919aF21C27845'); + const router = await ethers.getContractAt('ISwapRouter', '0xE592427A0AEce92De3Edee1F18E0157C05861564'); + const hypervisor = await ethers.getContractAt('IERC20', '0x52ee1ffba696c5e9b0bc177a9f8a3098420ea691'); + const [ signer ] = await ethers.getSigners(); + + const amount = ethers.utils.parseEther('0.5'); + // await router.exactOutput({ + // path: ethers.utils.solidityPack(['address', 'uint24', 'address'], [WBTC.address, 500, WETH.address]), + // recipient: signer.address, + // deadline: Math.floor(new Date / 1000) + 60, + // amountOut: 2000000, + // amountInMaximum: amount + // }, {value: amount}); + + // await WETH.deposit({value: amount}) + + // let {amountStart, amountEnd} = await uniproxy.getDepositAmount(hypervisor, WETH.address, await WETH.balanceOf(signer.address)); + // let wbtcBalance = await WBTC.balanceOf(signer.address); + // let divver = 10; + // while (wbtcBalance.lt(amountStart)) { + // const toSwapOut = amountEnd.sub(wbtcBalance).div(divver); + // const maxSwapIn = await WETH.balanceOf(signer.address); + // await WETH.approve(router.address, maxSwapIn); + // await router.exactOutput({ + // path: ethers.utils.solidityPack(['address', 'uint24', 'address'], [WBTC.address, 500, WETH.address]), + // recipient: signer.address, + // deadline: Math.floor(new Date / 1000) + 60, + // amountOut: toSwapOut, + // amountInMaximum: maxSwapIn + // }); + + // ({amountStart, amountEnd} = await uniproxy.getDepositAmount(hypervisor, WETH.address, await WETH.balanceOf(signer.address))); + // wbtcBalance = await WBTC.balanceOf(signer.address); + // if (divver > 2) divver--; + // console.log('amountStart', amountStart); + // console.log('amountEnd', amountEnd); + // console.log('wbtcBalance', wbtcBalance); + // console.log('divver', divver) + // console.log('---') + // } + + // console.log(await WETH.balanceOf(signer.address)); + // console.log(await uniproxy.getDepositAmount(hypervisor, WETH.address, await WETH.balanceOf(signer.address))); + // console.log(await WBTC.balanceOf(signer.address)); + + + // --------- +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/test/SmartVault.js b/test/SmartVault.js index 58d069d..4596ab1 100644 --- a/test/SmartVault.js +++ b/test/SmartVault.js @@ -4,7 +4,7 @@ const { expect } = require('chai'); const { DEFAULT_ETH_USD_PRICE, DEFAULT_EUR_USD_PRICE, DEFAULT_COLLATERAL_RATE, PROTOCOL_FEE_RATE, getCollateralOf, ETH, getNFTMetadataContract, fullyUpgradedSmartVaultManager, TEST_VAULT_LIMIT } = require('./common'); const { HUNDRED_PC } = require('./common'); -let VaultManager, Vault, TokenManager, ClEthUsd, EUROs, MockSwapRouter, MockWeth, admin, user, otherUser, protocol; +let VaultManager, Vault, TokenManager, ClEthUsd, EUROs, EURA, MockSwapRouter, MockWeth, admin, user, otherUser, protocol, YieldManager, UniProxyMock, MockEUROsHypervisor; describe('SmartVault', async () => { beforeEach(async () => { @@ -15,30 +15,41 @@ describe('SmartVault', async () => { await ClEurUsd.setPrice(DEFAULT_EUR_USD_PRICE); EUROs = await (await ethers.getContractFactory('EUROsMock')).deploy(); TokenManager = await (await ethers.getContractFactory('TokenManager')).deploy(ETH, ClEthUsd.address); - const SmartVaultDeployer = await (await ethers.getContractFactory('SmartVaultDeployerV3')).deploy(ETH, ClEurUsd.address); + const SmartVaultDeployer = await (await ethers.getContractFactory('SmartVaultDeployerV4')).deploy(ETH, ClEurUsd.address); const SmartVaultIndex = await (await ethers.getContractFactory('SmartVaultIndex')).deploy(); const NFTMetadataGenerator = await (await getNFTMetadataContract()).deploy(); MockSwapRouter = await (await ethers.getContractFactory('MockSwapRouter')).deploy(); MockWeth = await (await ethers.getContractFactory('MockWETH')).deploy(); + EURA = await (await ethers.getContractFactory('ERC20Mock')).deploy('EURA', 'EURA', 18); + UniProxyMock = await (await ethers.getContractFactory('UniProxyMock')).deploy(); + MockEUROsHypervisor = await (await ethers.getContractFactory('HypervisorMock')).deploy( + 'EUROs-EURA', 'EUROs-EURA', EUROs.address, EURA.address + ); + YieldManager = await (await ethers.getContractFactory('SmartVaultYieldManager')).deploy( + EUROs.address, EURA.address, MockWeth.address, UniProxyMock.address, MockSwapRouter.address, MockEUROsHypervisor.address, + MockSwapRouter.address + ); VaultManager = await fullyUpgradedSmartVaultManager( DEFAULT_COLLATERAL_RATE, PROTOCOL_FEE_RATE, EUROs.address, protocol.address, protocol.address, TokenManager.address, SmartVaultDeployer.address, SmartVaultIndex.address, NFTMetadataGenerator.address, MockWeth.address, - MockSwapRouter.address, TEST_VAULT_LIMIT + MockSwapRouter.address, TEST_VAULT_LIMIT, YieldManager.address ); + await YieldManager.setFeeData(PROTOCOL_FEE_RATE, VaultManager.address); await SmartVaultIndex.setVaultManager(VaultManager.address); await EUROs.grantRole(await EUROs.DEFAULT_ADMIN_ROLE(), VaultManager.address); + await EUROs.grantRole(await EUROs.MINTER_ROLE(), admin.address); await VaultManager.connect(user).mint(); const [ vaultID ] = await VaultManager.vaultIDs(user.address); const { status } = await VaultManager.vaultData(vaultID); const { vaultAddress } = status; - Vault = await ethers.getContractAt('SmartVaultV3', vaultAddress); + Vault = await ethers.getContractAt('SmartVaultV4', vaultAddress); }); describe('ownership', async () => { it('will not allow setting of new owner if not manager', async () => { const ownerUpdate = Vault.connect(user).setOwner(otherUser.address); - await expect(ownerUpdate).to.be.revertedWith('err-invalid-user'); + await expect(ownerUpdate).to.be.revertedWithCustomError(Vault, 'InvalidUser'); }); }); @@ -113,7 +124,7 @@ describe('SmartVault', async () => { expect(getCollateralOf('ETH', collateral).amount).to.equal(value); let remove = Vault.connect(otherUser).removeCollateralNative(value, user.address); - await expect(remove).to.be.revertedWith('err-invalid-user'); + await expect(remove).to.be.revertedWithCustomError(Vault, 'InvalidUser'); remove = Vault.connect(user).removeCollateralNative(half, user.address); await expect(remove).not.to.be.reverted; @@ -127,7 +138,7 @@ describe('SmartVault', async () => { // cannot remove any eth remove = Vault.connect(user).removeCollateralNative(ethers.utils.parseEther('0.0001'), user.address); - await expect(remove).to.be.revertedWith('err-under-coll'); + await expect(remove).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); }); it('allows removal of ERC20 if owner and it will not undercollateralise vault', async () => { @@ -147,7 +158,7 @@ describe('SmartVault', async () => { expect(getCollateralOf('USDT', collateral).amount).to.equal(value); let remove = Vault.connect(otherUser).removeCollateral(USDTBytes, value, user.address); - await expect(remove).to.be.revertedWith('err-invalid-user'); + await expect(remove).to.be.revertedWithCustomError(Vault, 'InvalidUser'); remove = Vault.connect(user).removeCollateral(USDTBytes, half, user.address); await expect(remove).not.to.be.reverted; @@ -161,7 +172,7 @@ describe('SmartVault', async () => { // cannot remove any eth remove = Vault.connect(user).removeCollateral(ethers.utils.formatBytes32String('USDT'), 1000000, user.address); - await expect(remove).to.be.revertedWith('err-under-coll'); + await expect(remove).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); }); it('allows removal of ERC20s that are or are not valid collateral, if not undercollateralising', async () => { @@ -184,13 +195,13 @@ describe('SmartVault', async () => { await Vault.connect(user).mint(user.address, maxMintable.div(2)); - await expect(Vault.removeAsset(SUSD6.address, SUSD6value, user.address)).to.be.revertedWith('err-invalid-user'); + await expect(Vault.removeAsset(SUSD6.address, SUSD6value, user.address)).to.be.revertedWithCustomError(Vault, 'InvalidUser'); await Vault.connect(user).removeAsset(SUSD6.address, SUSD6value, user.address); expect(await SUSD6.balanceOf(Vault.address)).to.equal(0); expect(await SUSD6.balanceOf(user.address)).to.equal(SUSD6value); - await expect(Vault.connect(user).removeAsset(SUSD18.address, SUSD18value, user.address)).to.be.revertedWith('err-under-coll'); + await expect(Vault.connect(user).removeAsset(SUSD18.address, SUSD18value, user.address)).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); // partial removal, because some needed as collateral const part = SUSD18value.div(3); @@ -205,13 +216,13 @@ describe('SmartVault', async () => { describe('minting', async () => { it('only allows the vault owner to mint from smart vault directly', async () => { const mintedValue = ethers.utils.parseEther('100'); - await expect(Vault.connect(user).mint(user.address, mintedValue)).to.be.revertedWith('err-under-coll'); + await expect(Vault.connect(user).mint(user.address, mintedValue)).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); const collateral = ethers.utils.parseEther('1'); await user.sendTransaction({to: Vault.address, value: collateral}); let mint = Vault.connect(otherUser).mint(user.address, mintedValue); - await expect(mint).to.be.revertedWith('err-invalid-user'); + await expect(mint).to.be.revertedWithCustomError(Vault, 'InvalidUser'); mint = Vault.connect(user).mint(user.address, mintedValue); await expect(mint).not.to.be.reverted; @@ -232,7 +243,7 @@ describe('SmartVault', async () => { const burnedValue = ethers.utils.parseEther('50'); let burn = Vault.connect(user).burn(burnedValue); - await expect(burn).to.be.revertedWith('err-insuff-minted'); + await expect(burn).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); // 100 to user // 1 to protocol @@ -283,12 +294,12 @@ describe('SmartVault', async () => { const mintedValue = ethers.utils.parseEther('900'); await Vault.connect(user).mint(user.address, mintedValue); - await expect(VaultManager.connect(protocol).liquidateVault(1)).to.be.revertedWith('vault-not-undercollateralised'); + await expect(VaultManager.connect(protocol).liquidateVault(1)).to.be.revertedWith('vault-not-undercollateralised') // drop price, now vault is liquidatable await ClEthUsd.setPrice(100000000000); - await expect(Vault.liquidate()).to.be.revertedWith('err-invalid-user'); + await expect(Vault.liquidate()).to.be.revertedWithCustomError(Vault, 'InvalidUser'); await expect(VaultManager.connect(protocol).liquidateVault(1)).not.to.be.reverted; const { minted, maxMintable, totalCollateralValue, collateral, liquidated } = await Vault.status(); @@ -314,7 +325,7 @@ describe('SmartVault', async () => { expect(liquidated).to.equal(true); await user.sendTransaction({to: Vault.address, value: ethValue.mul(2)}); - await expect(Vault.connect(user).mint(user.address, mintedValue)).to.be.revertedWith('err-liquidated'); + await expect(Vault.connect(user).mint(user.address, mintedValue)).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); }); }); @@ -335,7 +346,7 @@ describe('SmartVault', async () => { const swapValue = ethers.utils.parseEther('0.5'); const swap = Vault.connect(admin).swap(inToken, outToken, swapValue, 0); - await expect(swap).to.be.revertedWith('err-invalid-user'); + await expect(swap).to.be.revertedWithCustomError(Vault, 'InvalidUser'); }); it('invokes swaprouter with value for eth swap, paying fees to protocol', async () => { @@ -351,6 +362,12 @@ describe('SmartVault', async () => { const swapFee = swapValue.mul(PROTOCOL_FEE_RATE).div(HUNDRED_PC); const protocolBalance = await protocol.getBalance(); + + // load up mock swap router + await Stablecoin.mint(MockSwapRouter.address, 1_000_000_000_000); + // rate of eth / usd is default rate, scaled down from 8 dec (chainlink) to 6 dec (stablecoin decimals) + await MockSwapRouter.setRate(MockWeth.address, Stablecoin.address, DEFAULT_ETH_USD_PRICE / 100); + const swap = await Vault.connect(user).swap(inToken, outToken, swapValue, 0); const ts = (await ethers.provider.getBlock(swap.blockNumber)).timestamp; @@ -390,6 +407,12 @@ describe('SmartVault', async () => { // even if swap returned 0 assets, vault would remain above €600 required collateral value // minimum swap therefore 0 const protocolBalance = await protocol.getBalance(); + + // load up mock swap router + await Stablecoin.mint(MockSwapRouter.address, 1_000_000_000_000); + // rate of eth / usd is default rate, scaled down from 8 dec (chainlink) to 6 dec (stablecoin decimals) + await MockSwapRouter.setRate(MockWeth.address, Stablecoin.address, DEFAULT_ETH_USD_PRICE / 100); + const swap = await Vault.connect(user).swap(inToken, outToken, swapValue, swapMinimum); const ts = (await ethers.provider.getBlock(swap.blockNumber)).timestamp; @@ -417,6 +440,14 @@ describe('SmartVault', async () => { const swapValue = ethers.utils.parseEther('50'); const swapFee = swapValue.mul(PROTOCOL_FEE_RATE).div(HUNDRED_PC); const actualSwap = swapValue.sub(swapFee); + + // load up mock weth + await admin.sendTransaction({ to: MockWeth.address, value: ethers.utils.parseEther('1') }); + // load up mock swap router + await MockWeth.mint(MockSwapRouter.address, ethers.utils.parseEther('1')); + // rate of usd / eth is 1 / DEFAULT RATE * 10 ^ 20 (to scale from 6 dec to 18, and remove 8 dec scale down from chainlink price) + await MockSwapRouter.setRate(Stablecoin.address, MockWeth.address, BigNumber.from(10).pow(20).div(DEFAULT_ETH_USD_PRICE)); + const swap = await Vault.connect(user).swap(inToken, outToken, swapValue, 0); const ts = (await ethers.provider.getBlock(swap.blockNumber)).timestamp; @@ -436,4 +467,234 @@ describe('SmartVault', async () => { expect(await Stablecoin.balanceOf(protocol.address)).to.equal(swapFee); }); }); + + describe('yield', async () => { + let WBTC, USDC, WBTCPerETH, MockWETHWBTCHypervisor; + + beforeEach(async () => { + WBTC = await (await ethers.getContractFactory('ERC20Mock')).deploy('Wrapped Bitcoin', 'WBTC', 8); + USDC = await (await ethers.getContractFactory('ERC20Mock')).deploy('USD Coin', 'USDC', 6); + const CL_WBTC_USD = await (await ethers.getContractFactory('ChainlinkMock')).deploy('WBTC / USD'); + await CL_WBTC_USD.setPrice(DEFAULT_ETH_USD_PRICE.mul(20)); + const CL_USDC_USD = await (await ethers.getContractFactory('ChainlinkMock')).deploy('USDC / USD'); + await CL_USDC_USD.setPrice(BigNumber.from(10).pow(8)); + await TokenManager.addAcceptedToken(WBTC.address, CL_WBTC_USD.address); + await TokenManager.addAcceptedToken(MockWeth.address, ClEthUsd.address); + await TokenManager.addAcceptedToken(USDC.address, CL_USDC_USD.address); + + // fake gamma vault for WETH + WBTC + MockWETHWBTCHypervisor = await (await ethers.getContractFactory('HypervisorMock')).deploy( + 'WETH-WBTC', 'WETH-WBTC', MockWeth.address, WBTC.address + ); + + // data about how yield manager converts collateral to EURA, vault addresses etc + await YieldManager.addHypervisorData( + MockWeth.address, MockWETHWBTCHypervisor.address, 500, + new ethers.utils.AbiCoder().encode(['address', 'uint24', 'address'], [MockWeth.address, 3000, EURA.address]), + new ethers.utils.AbiCoder().encode(['address', 'uint24', 'address'], [EURA.address, 3000, MockWeth.address]) + ) + await YieldManager.addHypervisorData( + WBTC.address, MockWETHWBTCHypervisor.address, 500, + new ethers.utils.AbiCoder().encode(['address', 'uint24', 'address'], [WBTC.address, 3000, EURA.address]), + new ethers.utils.AbiCoder().encode(['address', 'uint24', 'address'], [EURA.address, 3000, WBTC.address]) + ) + + // ratio of euros vault is 1:1 + await UniProxyMock.setRatio(MockEUROsHypervisor.address, EURA.address, ethers.utils.parseEther('1')); + // ratio of weth / wbtc vault is 1:1 in value, or 20:1 in unscaled numbers (20*10**10:1) in scaled + WBTCPerETH = ethers.utils.parseUnits('0.05',8) + await UniProxyMock.setRatio(MockWETHWBTCHypervisor.address, MockWeth.address, WBTCPerETH); + // ratio is inverse of above, 1:20 in unscaled numbers, or 1:20*10^8 + await UniProxyMock.setRatio(MockWETHWBTCHypervisor.address, WBTC.address, ethers.utils.parseUnits('20',28)); + + // set fake rate for swap router: this is ETH / EUROs: ~1500 + await MockSwapRouter.setRate(MockWeth.address, EURA.address, DEFAULT_ETH_USD_PRICE.mul(ethers.utils.parseEther('1')).div(DEFAULT_EUR_USD_PRICE)); + await MockSwapRouter.setRate(EURA.address, MockWeth.address, ethers.utils.parseEther('1').mul(DEFAULT_EUR_USD_PRICE).div(DEFAULT_ETH_USD_PRICE)); + // set fake rate for EURA / EURO: 1:1 + await MockSwapRouter.setRate(EURA.address, EUROs.address, ethers.utils.parseEther('1')); + await MockSwapRouter.setRate(EUROs.address, EURA.address, ethers.utils.parseEther('1')); + // set fake rate for ETH / WBTC: 0.05 WBTC scaled down to 8 dec + await MockSwapRouter.setRate(MockWeth.address, WBTC.address, WBTCPerETH); + // set fake rate for WBTC / EUROS: ~30.1k, with scaling up by 10 dec + await MockSwapRouter.setRate(WBTC.address, EURA.address, DEFAULT_ETH_USD_PRICE.mul(20).mul(ethers.utils.parseUnits('1', 28)).div(DEFAULT_EUR_USD_PRICE)) + // set fake rate for WBTC / ETH: 20 ETH scaled up by 10 dec + await MockSwapRouter.setRate(WBTC.address, MockWeth.address, ethers.utils.parseUnits('20',28)) + + // load up mock swap router + await EURA.mint(MockSwapRouter.address, ethers.utils.parseEther('1000000')); + await EUROs.mint(MockSwapRouter.address, ethers.utils.parseEther('1000000')); + await WBTC.mint(MockSwapRouter.address, ethers.utils.parseUnits('10', 8)); + await MockWeth.mint(MockSwapRouter.address, ethers.utils.parseEther('10')); + }); + + it('fetches empty yield list', async () => { + expect(await Vault.yieldAssets()).to.be.empty; + }); + + it('puts all of given collateral asset into yield', async () => { + const ethCollateral = ethers.utils.parseEther('0.1') + await user.sendTransaction({ to: Vault.address, value: ethCollateral }); + + let { collateral, totalCollateralValue } = await Vault.status(); + let preYieldCollateral = totalCollateralValue; + expect(getCollateralOf('ETH', collateral).amount).to.equal(ethCollateral); + + // only vault owner can deposit collateral as yield + let depositYield = Vault.connect(admin).depositYield(ETH, HUNDRED_PC.div(2)); + await expect(depositYield).to.be.revertedWithCustomError(Vault, 'InvalidUser'); + // 5% to stables pool is below minimum + depositYield = Vault.connect(user).depositYield(ETH, HUNDRED_PC.div(20)); + await expect(depositYield).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); + depositYield = Vault.connect(user).depositYield(ETH, HUNDRED_PC.div(2)); + await expect(depositYield).not.to.be.reverted; + await expect(depositYield).to.emit(YieldManager, 'Deposit').withArgs(Vault.address, MockWeth.address, ethCollateral, HUNDRED_PC.div(2)); + + // USDC does not have hypervisor data set in yield manager + const USDCBytes = ethers.utils.formatBytes32String('USDC'); + const USDCCollateral = ethers.utils.parseUnits('1000', 6); + await USDC.mint(Vault.address, USDCCollateral); + await expect(Vault.connect(user).depositYield(USDCBytes, HUNDRED_PC.div(2))).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); + await Vault.connect(user).removeCollateral(USDCBytes, USDCCollateral, user.address); + + ({ collateral, totalCollateralValue } = await Vault.status()); + expect(getCollateralOf('ETH', collateral).amount).to.equal(0); + // allow a delta of 2 wei in pre and post yield collateral, due to dividing etc + expect(totalCollateralValue).to.be.closeTo(preYieldCollateral, 2); + + const yieldAssets = await Vault.yieldAssets(); + expect(yieldAssets).to.have.length(2); + expect([EUROs.address, EURA.address]).to.include(yieldAssets[0].token0); + expect([EUROs.address, EURA.address]).to.include(yieldAssets[0].token1); + expect(yieldAssets[0].amount0).to.be.closeTo(preYieldCollateral.div(4), 1); + expect(yieldAssets[0].amount1).to.be.closeTo(preYieldCollateral.div(4), 1); + expect([WBTC.address, MockWeth.address]).to.include(yieldAssets[1].token0); + expect([WBTC.address, MockWeth.address]).to.include(yieldAssets[1].token1); + expect(yieldAssets[1].amount0).to.equal(ethCollateral.div(4), 1); + // 0.1 ETH, quarter of which should be wbtc + expect(yieldAssets[1].amount1).to.be.closeTo(WBTCPerETH / 40, 1); + + // add wbtc as collateral + const wbtcCollateral = ethers.utils.parseUnits('0.005',8) + await WBTC.mint(Vault.address, wbtcCollateral); + + ({ collateral, totalCollateralValue } = await Vault.status()); + expect(getCollateralOf('WBTC', collateral).amount).to.equal(wbtcCollateral); + preYieldCollateral = totalCollateralValue; + + // deposit wbtc for yield, 25% to euros pool + depositYield = Vault.connect(user).depositYield(ethers.utils.formatBytes32String('WBTC'), HUNDRED_PC.div(4)); + await expect(depositYield).to.emit(YieldManager, 'Deposit').withArgs(Vault.address, WBTC.address, wbtcCollateral, HUNDRED_PC.div(4)); // bit of accuracy issue + ({ collateral, totalCollateralValue } = await Vault.status()); + expect(getCollateralOf('WBTC', collateral).amount).to.equal(0); + expect(totalCollateralValue).to.be.closeTo(preYieldCollateral, 1); + // TODO assertions on the yield assets for wbtc deposit + }); + + it('allows deleting of yield data for a collateral type (and reverts)', async () => { + const ethCollateral = ethers.utils.parseEther('0.1') + await user.sendTransaction({ to: Vault.address, value: ethCollateral }); + + await expect(Vault.connect(user).depositYield(ETH, HUNDRED_PC.div(2))).not.to.be.reverted; + + await expect(YieldManager.connect(user).removeHypervisorData(MockWeth.address)).to.be.revertedWith('Ownable: caller is not the owner'); + await expect(YieldManager.connect(admin).removeHypervisorData(MockWeth.address)).not.to.be.reverted; + + await user.sendTransaction({ to: Vault.address, value: ethCollateral }); + await expect(Vault.connect(user).depositYield(ETH, HUNDRED_PC.div(2))).to.be.revertedWithCustomError(YieldManager, 'InvalidRequest'); + }); + + it('withdraw yield deposits by vault', async () => { + const ethCollateral = ethers.utils.parseEther('0.1'); + await user.sendTransaction({ to: Vault.address, value: ethCollateral }); + + // 25% yield to stable pool + await Vault.connect(user).depositYield(ETH, HUNDRED_PC.div(4)); + expect(await Vault.yieldAssets()).to.have.length(2); + const status = await Vault.status(); + const preWithdrawCollateralValue = status.totalCollateralValue; + expect(getCollateralOf('ETH', status.collateral).amount).to.equal(0); + const [ EUROsYield ] = await Vault.yieldAssets(); + + let withdrawYield = Vault.connect(user).withdrawYield(EUROsYield.hypervisor, ETH); + let protocolFee = ethCollateral.div(4).mul(PROTOCOL_FEE_RATE).div(HUNDRED_PC); + await expect(withdrawYield).to.emit(YieldManager, 'Withdraw').withArgs(Vault.address, MockWeth.address, MockEUROsHypervisor.address, ethCollateral.div(4).sub(protocolFee)) // bit of an accuracy issue + let { totalCollateralValue, collateral } = await Vault.status(); + // ~99.875% of collateral expected because of protocol fee on quarter of yield withdrawal + let expectedCollateral = preWithdrawCollateralValue.mul(99875).div(100000); + expect(totalCollateralValue).to.be.closeTo(expectedCollateral, 2000); + const yieldAssets = await Vault.yieldAssets(); + expect(yieldAssets).to.have.length(1); + expect(yieldAssets[0].hypervisor).to.equal(MockWETHWBTCHypervisor.address); + // should have withdrawn ~quarter of eth collateral, because that much was put in stable pool originally, minus protocol fee + expect(getCollateralOf('ETH', collateral).amount).to.be.closeTo(ethCollateral.div(4).sub(protocolFee), 1); + expect(await MockWeth.balanceOf(protocol.address)).to.be.closeTo(protocolFee, 1); + await Vault.connect(user).withdrawYield(MockWETHWBTCHypervisor.address, ethers.utils.formatBytes32String('WBTC')); + ({ totalCollateralValue, collateral } = await Vault.status()); + // ~99.5% of original collateral because all collateral withdrawn with .5% protocol fee rate + expectedCollateral = preWithdrawCollateralValue.mul(HUNDRED_PC.sub(PROTOCOL_FEE_RATE)).div(HUNDRED_PC); + expect(totalCollateralValue).to.be.closeTo(expectedCollateral, 2000); + expect(await Vault.yieldAssets()).to.be.empty; + // wbtc amount should be roughly equal to 0.075 ETH = 0.075 + const WBTCWithdrawal = WBTCPerETH.mul(ethCollateral.mul(3).div(4)).div(ethers.utils.parseEther('1')); + protocolFee = WBTCWithdrawal.mul(PROTOCOL_FEE_RATE).div(HUNDRED_PC); + const expectedWBTC = WBTCWithdrawal.sub(protocolFee); + expect(getCollateralOf('WBTC', collateral).amount).to.equal(expectedWBTC); + expect(await WBTC.balanceOf(protocol.address)).to.equal(protocolFee); + }); + + it('reverts when collateral asset is not compatible with given asset on withdrawal', async () => { + const ethCollateral = ethers.utils.parseEther('0.1'); + await user.sendTransaction({ to: Vault.address, value: ethCollateral }); + await Vault.connect(user).depositYield(ETH, HUNDRED_PC.div(2)); + + // add usdc hypervisor data + const MockUSDCWETHHypervisor = await (await ethers.getContractFactory('HypervisorMock')).deploy( + 'USDC-WETH', 'USDC-WETH', USDC.address, MockWeth.address + ); + // only allows own to set hypervisor data + await expect(YieldManager.connect(user).addHypervisorData( + USDC.address, MockUSDCWETHHypervisor.address, 500, + new ethers.utils.AbiCoder().encode(['address', 'uint24', 'address'], [USDC.address, 3000, EURA.address]), + new ethers.utils.AbiCoder().encode(['address', 'uint24', 'address'], [EURA.address, 3000, USDC.address]) + )).to.be.revertedWith('Ownable: caller is not the owner'); + + await expect(YieldManager.addHypervisorData( + USDC.address, MockUSDCWETHHypervisor.address, 500, + new ethers.utils.AbiCoder().encode(['address', 'uint24', 'address'], [USDC.address, 3000, EURA.address]), + new ethers.utils.AbiCoder().encode(['address', 'uint24', 'address'], [EURA.address, 3000, USDC.address]) + )).not.to.be.reverted; + + // weth / wbtc hypervisor cannot be withdrawn to usdc, even tho there is usdc hypervisor data + await expect(Vault.connect(user).withdrawYield(MockWETHWBTCHypervisor.address, ethers.utils.formatBytes32String('USDC'))) + .to.be.revertedWithCustomError(YieldManager, 'InvalidRequest'); + }) + + it('reverts if collateral level falls below required level during deposit or withdrawal', async () => { + const ethCollateral = ethers.utils.parseEther('0.1'); + await user.sendTransaction({ to: Vault.address, value: ethCollateral }); + // should be able to borrow up to about €125 + // borrowing 120 to allow for minting fee + await Vault.connect(user).mint(user.address, ethers.utils.parseEther('120')); + + // ETH / WBTC swap price halves, so yield value is significantly lower than ETH collateral value + await MockSwapRouter.setRate(MockWeth.address, WBTC.address, WBTCPerETH.div(2)); + + await expect(Vault.connect(user).depositYield(ETH, HUNDRED_PC.div(2))).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); + + // reset ETH / WBTC to normal rate + await MockSwapRouter.setRate(MockWeth.address, WBTC.address, WBTCPerETH); + await Vault.connect(user).depositYield(ETH, HUNDRED_PC.div(2)); + + // WBTC / ETH swap price halves, so yield value is significantly lower than ETH collateral value + await MockSwapRouter.setRate(WBTC.address, MockWeth.address, ethers.utils.parseUnits('10',28)) + + await expect(Vault.connect(user).withdrawYield(MockWETHWBTCHypervisor.address, ETH)).to.be.revertedWithCustomError(Vault, 'InvalidRequest'); + + }); + + it('only allows owner to set fee data', async() => { + await expect(YieldManager.connect(user).setFeeData(1000, ethers.constants.AddressZero)).to.be.revertedWith('Ownable: caller is not the owner'); + await expect(YieldManager.connect(admin).setFeeData(1000, ethers.constants.AddressZero)).not.to.be.reverted; + }); + }); }); \ No newline at end of file diff --git a/test/common.js b/test/common.js index 689f2b7..fda1ac7 100644 --- a/test/common.js +++ b/test/common.js @@ -27,7 +27,7 @@ const fullyUpgradedSmartVaultManager = async ( collateralRate, protocolFeeRate, eurosAddress, protocolAddress, liquidatorAddress, tokenManagerAddress, smartVaultDeployerAddress, smartVaultIndexAddress, nFTMetadataGeneratorAddress, wethAddress, - swapRouterAddress, vaultLimit + swapRouterAddress, vaultLimit, yieldManagerAddress ) => { const v1 = await upgrades.deployProxy(await ethers.getContractFactory('SmartVaultManager'), [ collateralRate, protocolFeeRate, eurosAddress, protocolAddress, @@ -39,13 +39,15 @@ const fullyUpgradedSmartVaultManager = async ( await upgrades.upgradeProxy(v1.address, await ethers.getContractFactory('SmartVaultManagerV2')); await upgrades.upgradeProxy(v1.address, await ethers.getContractFactory('SmartVaultManagerV3')); await upgrades.upgradeProxy(v1.address, await ethers.getContractFactory('SmartVaultManagerV4')); - const V5 = await upgrades.upgradeProxy(v1.address, await ethers.getContractFactory('SmartVaultManagerV5')); + await upgrades.upgradeProxy(v1.address, await ethers.getContractFactory('SmartVaultManagerV5')); + const V6 = await upgrades.upgradeProxy(v1.address, await ethers.getContractFactory('SmartVaultManagerV6')); - await V5.setSwapFeeRate(protocolFeeRate); - await V5.setWethAddress(wethAddress); - await V5.setSwapRouter2(swapRouterAddress); - await V5.setUserVaultLimit(vaultLimit); - return V5; + await V6.setSwapFeeRate(protocolFeeRate); + await V6.setWethAddress(wethAddress); + await V6.setSwapRouter2(swapRouterAddress); + await V6.setYieldManager(yieldManagerAddress); + await V6.setUserVaultLimit(vaultLimit); + return V6; } module.exports = { diff --git a/test/smartVaultManager.js b/test/smartVaultManager.js index aef7278..3cd34ba 100644 --- a/test/smartVaultManager.js +++ b/test/smartVaultManager.js @@ -19,7 +19,7 @@ describe('SmartVaultManager', async () => { TokenManager = await (await ethers.getContractFactory('TokenManager')).deploy(ETH, ClEthUsd.address); EUROs = await (await ethers.getContractFactory('EUROsMock')).deploy(); Tether = await (await ethers.getContractFactory('ERC20Mock')).deploy('Tether', 'USDT', 6); - SmartVaultDeployer = await (await ethers.getContractFactory('SmartVaultDeployerV3')).deploy(ETH, ClEurUsd.address); + SmartVaultDeployer = await (await ethers.getContractFactory('SmartVaultDeployerV4')).deploy(ETH, ClEurUsd.address); const SmartVaultIndex = await (await ethers.getContractFactory('SmartVaultIndex')).deploy(); MockSwapRouter = await (await ethers.getContractFactory('MockSwapRouter')).deploy(); NFTMetadataGenerator = await (await getNFTMetadataContract()).deploy(); @@ -27,7 +27,7 @@ describe('SmartVaultManager', async () => { DEFAULT_COLLATERAL_RATE, PROTOCOL_FEE_RATE, EUROs.address, protocol.address, liquidator.address, TokenManager.address, SmartVaultDeployer.address, SmartVaultIndex.address, NFTMetadataGenerator.address, WETH_ADDRESS, - MockSwapRouter.address, TEST_VAULT_LIMIT + MockSwapRouter.address, TEST_VAULT_LIMIT, ethers.constants.AddressZero ); await SmartVaultIndex.setVaultManager(VaultManager.address); await EUROs.grantRole(await EUROs.DEFAULT_ADMIN_ROLE(), VaultManager.address); @@ -215,5 +215,12 @@ describe('SmartVaultManager', async () => { expect(metadataJSON).to.have.string('base64'); }); }); + + describe('vault version', async () => { + it('deploys v4 vaults', async () => { + const vault = await ethers.getContractAt('SmartVault', vaultAddress); + expect((await vault.status()).version).to.equal(4); + }); + }); }); }); \ No newline at end of file diff --git a/test/svgGenerator.js b/test/svgGenerator.js index 4a859e4..ded8f99 100644 --- a/test/svgGenerator.js +++ b/test/svgGenerator.js @@ -16,7 +16,7 @@ const getSvgMintConfig = (minted, maxMintable, totalCollateralValue) => { describe('SVG Generator', async () => { // uncomment to show svg - let printViewableSvgInTest = true; + let printViewableSvgInTest = false; let svgGenerator; beforeEach(async () => { diff --git a/test/versioning.js b/test/versioning.js index 65b7bf2..652b5bf 100644 --- a/test/versioning.js +++ b/test/versioning.js @@ -29,13 +29,13 @@ describe('Contract Versioning', async () => { expect(v1Vault.status.vaultType).to.equal(ethers.utils.formatBytes32String('EUROs')); // version smart vault manager, to deploy v3 with different vaults - const VaultDeployerV3 = await (await ethers.getContractFactory('TestSmartVaultDeployerV2')).deploy(ETH, ClEurUsd.address); + const VaultDeployerV2 = await (await ethers.getContractFactory('TestSmartVaultDeployerV2')).deploy(ETH, ClEurUsd.address); const TokenManagerV2 = await (await ethers.getContractFactory('TokenManager')).deploy(ETH, ClEthUsd.address); // try upgrading with non-owner let upgrade = upgrades.upgradeProxy(VaultManagerV1.address, await ethers.getContractFactory('TestSmartVaultManagerV2', user), { - call: {fn: 'completeUpgrade', args: [VaultDeployerV3.address]} + call: {fn: 'completeUpgrade', args: [VaultDeployerV2.address]} } ); @@ -43,7 +43,7 @@ describe('Contract Versioning', async () => { upgrade = upgrades.upgradeProxy(VaultManagerV1.address, await ethers.getContractFactory('TestSmartVaultManagerV2'), { - call: {fn: 'completeUpgrade', args: [VaultDeployerV3.address]} + call: {fn: 'completeUpgrade', args: [VaultDeployerV2.address]} } );