diff --git a/.eslintignore b/.eslintignore index 7167fc07c..29d80a369 100644 --- a/.eslintignore +++ b/.eslintignore @@ -11,5 +11,6 @@ specification_docs/ deployments/ contracts/protocol/earn-protocol-configuration contracts/protocol/adapters +contracts/protocol/optyfi-swapper # files .solcover.js \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 6b7e0473c..19f771b24 100644 --- a/.gitmodules +++ b/.gitmodules @@ -37,6 +37,10 @@ [submodule "contracts/protocol/adapters/ethereum/sushiswap-pool-adapter-ethereum"] path = contracts/protocol/adapters/ethereum/sushiswap-pool-adapter-ethereum url = git@github.com:Opty-Fi/sushiswap-pool-adapter-ethereum.git +[submodule "contracts/protocol/optyfi-swapper"] + path = contracts/protocol/optyfi-swapper + url = git@github.com:Opty-Fi/optyfi-swapper.git + branch = swap [submodule "contracts/protocol/adapters/ethereum/team-defi-adapters"] path = contracts/protocol/adapters/ethereum/team-defi-adapters url = git@github.com:Opty-Fi/team-defi-adapters.git diff --git a/.prettierignore b/.prettierignore index aa72956ef..3cf2d3026 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,6 +13,8 @@ **/specification_docs contracts/protocol/earn-protocol-configuration contracts/protocol/adapters +contracts/protocol/optyfi-swapper +contracts/protocol/zap # files *.env diff --git a/.solhintignore b/.solhintignore index 9e4b82f92..4059a6978 100644 --- a/.solhintignore +++ b/.solhintignore @@ -7,4 +7,6 @@ contracts/utils/ contracts/dependencies/ contracts/mocks/ contracts/protocol/earn-protocol-configuration -contracts/protocol/adapters \ No newline at end of file +contracts/protocol/adapters +contracts/protocol/optyfi-swapper +contracts/protocol/zap \ No newline at end of file diff --git a/contracts/protocol/optyfi-swapper b/contracts/protocol/optyfi-swapper new file mode 160000 index 000000000..4c9fab7cd --- /dev/null +++ b/contracts/protocol/optyfi-swapper @@ -0,0 +1 @@ +Subproject commit 4c9fab7cd5a1b0aef0aa0207e4db7e1b020bd565 diff --git a/contracts/protocol/zap/IZap.sol b/contracts/protocol/zap/IZap.sol new file mode 100644 index 000000000..0da9384a5 --- /dev/null +++ b/contracts/protocol/zap/IZap.sol @@ -0,0 +1,55 @@ +//SPDX-license-identifier: MIT +pragma solidity ^0.8.15; + +import { DataTypes } from "./lib/DataTypes.sol"; +import { ZapStorage } from "./ZapStorage.sol"; + +/** + * @title OptyFiZapper interface + * @author OptyFi + */ +interface IZap { + /** + * @notice performs an arbitrary swap of a given token or ETH to deposit in a OptyFi Vault + * @param _token the address of the input token + * @param _amount input token amount to deposit + * @param _permitParams ERC2612 permit params + * @param _zapParams the zapParams for the zap to be performed + * @return the shares received from the vault deposit + */ + function zapIn( + address _token, + uint256 _amount, + bytes memory _permitParams, + DataTypes.ZapData memory _zapParams + ) external payable returns (uint256); + + /** + * @notice redeems the vault shares and performs an arbitrary swap + * from the OptyFi Vault underlying token to any given token + * @param _token the address of the input token + * @param _amount input token amount to deposit + * @param _permitParams ERC2612 permit params + * @param _zapParams the zapParams for the zap to be performed + * @return the amount of output tokens received + */ + function zapOut( + address _token, + uint256 _amount, + bytes memory _permitParams, + DataTypes.ZapData memory _zapParams + ) external returns (uint256); + + /** + * @notice set swapper address + * @param _swapper swapper address + */ + function setSwapper(address _swapper) external; + + /** + * @notice set the zap contract merkle proof to deposit and withdraw on vault + * @param _vault address of the target vault + * @param _merkleProof zapper contract merkle proof + */ + function setMerkleProof(address _vault, bytes32[] memory _merkleProof) external; +} diff --git a/contracts/protocol/zap/IZapView.sol b/contracts/protocol/zap/IZapView.sol new file mode 100644 index 000000000..e11dc0550 --- /dev/null +++ b/contracts/protocol/zap/IZapView.sol @@ -0,0 +1,20 @@ +//SPDX-license-identifier: MIT +pragma solidity ^0.8.15; + +import { ISwapper } from "../optyfi-swapper/contracts/swap/ISwapper.sol"; + +/** + * @title ZapperView interface + * @author OptyFi + */ +interface IZapView { + /** + * @notice get swapper address + */ + function getSwapper() external view returns (ISwapper); + + /** + * @notice get merkle proof on a given vault + */ + function getMerkleProof(address _vault) external view returns (bytes32[] memory); +} diff --git a/contracts/protocol/zap/OptyFiZapper.sol b/contracts/protocol/zap/OptyFiZapper.sol new file mode 100644 index 000000000..96d6681b9 --- /dev/null +++ b/contracts/protocol/zap/OptyFiZapper.sol @@ -0,0 +1,16 @@ +//SPDX-license-identifier: MIT +pragma solidity ^0.8.15; + +import { SolidStateDiamond } from "@solidstate/contracts/proxy/diamond/SolidStateDiamond.sol"; +import { ISwapper } from "../optyfi-swapper/contracts/swap/ISwapper.sol"; +import { ZapStorage } from "./ZapStorage.sol"; + +/** + * @title Diamond proxy for OptyFiZapper + * @author OptyFi + */ +contract OptyFiZapper is SolidStateDiamond { + constructor(address _swapper) { + ZapStorage.layout().swapper = ISwapper(_swapper); + } +} diff --git a/contracts/protocol/zap/Zap.sol b/contracts/protocol/zap/Zap.sol new file mode 100644 index 000000000..096b86eb5 --- /dev/null +++ b/contracts/protocol/zap/Zap.sol @@ -0,0 +1,67 @@ +//SPDX-license-identifier: MIT +pragma solidity ^0.8.15; + +// helper contracts +import { ZapInternal } from "./ZapInternal.sol"; +import { OwnableInternal } from "@solidstate/contracts/access/ownable/OwnableInternal.sol"; + +// libraries +import { DataTypes } from "./lib/DataTypes.sol"; +import { ZapStorage } from "./ZapStorage.sol"; + +// interfaces +import { IZap } from "./IZap.sol"; + +/** + * @title Zap + * @author OptyFi + */ +contract Zap is IZap, ZapInternal, OwnableInternal { + using ZapStorage for ZapStorage.Layout; + + /** + * @inheritdoc IZap + */ + function zapIn( + address _token, + uint256 _amount, + bytes memory _permitParams, + DataTypes.ZapData memory _zapParams + ) external payable override returns (uint256 sharesReceived) { + ZapStorage.Layout storage _l = ZapStorage.layout(); + + sharesReceived = _zapIn(_l, _token, _amount, _permitParams, _zapParams); + } + + /** + * @inheritdoc IZap + */ + function zapOut( + address _token, + uint256 _amount, + bytes memory _permitParams, + DataTypes.ZapData memory _zapParams + ) external override returns (uint256 receivedAmount) { + ZapStorage.Layout storage _l = ZapStorage.layout(); + + receivedAmount = _zapOut(_l, _token, _amount, _permitParams, _zapParams); + } + + /** + * @inheritdoc IZap + */ + function setSwapper(address _swapper) external override onlyOwner { + ZapStorage.Layout storage _l = ZapStorage.layout(); + + _setSwapper(_l, _swapper); + } + + /** + * @inheritdoc IZap + */ + function setMerkleProof(address _vault, bytes32[] memory _merkleProof) external override onlyOwner { + ZapStorage.Layout storage _l = ZapStorage.layout(); + + _setMerkleProof(_l, _vault, _merkleProof); + } +} diff --git a/contracts/protocol/zap/ZapInternal.sol b/contracts/protocol/zap/ZapInternal.sol new file mode 100644 index 000000000..82eb4dc42 --- /dev/null +++ b/contracts/protocol/zap/ZapInternal.sol @@ -0,0 +1,214 @@ +//SPDX-license-identifier: MIT +pragma solidity ^0.8.15; + +// libraries +import { ZapStorage } from "./ZapStorage.sol"; +import { Errors } from "./lib/Errors.sol"; +import { DataTypes } from "./lib/DataTypes.sol"; +import { DataTypes as SwapTypes } from "../optyfi-swapper/contracts/swap/DataTypes.sol"; + +// interfaces +import { SafeERC20 } from "@solidstate/contracts/utils/SafeERC20.sol"; +import { IWETH } from "@solidstate/contracts/utils/IWETH.sol"; +import { IERC20 } from "@solidstate/contracts/token/ERC20/IERC20.sol"; +import { IERC2612 } from "@solidstate/contracts/token/ERC20/permit/IERC2612.sol"; +import { ISwapper } from "../optyfi-swapper/contracts/swap/ISwapper.sol"; +import { IVault } from "./interfaces/IVault.sol"; + +/** + * @title OptyFiZapper + * @author OptyFi + */ +abstract contract ZapInternal { + using ZapStorage for ZapStorage.Layout; + using SafeERC20 for IERC20; + + address public constant ETH = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + + /** + * @dev performs an arbitrary swap of a given token or ETH to deposit in a OptyFi Vault + * @param _l the zap layout struct + * @param _token the address of the input token + * @param _amount input token amount to deposit + * @param _permitParams ERC2612 permit params + * @param _zapParams the zapParams for the zap to be performed + * @return sharesReceived shares received from the vault deposit + */ + function _zapIn( + ZapStorage.Layout storage _l, + address _token, + uint256 _amount, + bytes memory _permitParams, + DataTypes.ZapData memory _zapParams + ) internal returns (uint256 sharesReceived) { + address vaultUnderlyingToken = _underlying(_zapParams.vault); + if (vaultUnderlyingToken == _token) { + revert Errors.InvalidToken(); + } + + ISwapper swapper = _getSwapper(); + + if (_token != ETH) { + _permit(_token, _permitParams); + IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount); + _approveTokenIfNeeded(_token, swapper.tokenTransferProxy(), _amount); + } + + SwapTypes.SwapData memory swapData = SwapTypes.SwapData( + _token, + vaultUnderlyingToken, + _amount, + _zapParams.toAmount, + _zapParams.callees, + _zapParams.exchangeData, + _zapParams.startIndexes, + _zapParams.values, + payable(address(this)), + _zapParams.permit, + _zapParams.deadline + ); + + (uint256 receivedAmount, uint256 unused) = swapper.swap{ value: msg.value }(swapData); + if (unused > 0) { + IERC20(_token).safeTransfer(msg.sender, unused); + } + + _approveTokenIfNeeded(vaultUnderlyingToken, _zapParams.vault, receivedAmount); + + sharesReceived = IVault(_zapParams.vault).userDepositVault( + msg.sender, + receivedAmount, + _zapParams.permit, + _l.merkleProofs[_zapParams.vault] + ); + } + + /** + * @dev redeems the vault shares and performs an arbitrary swap + * from the OptyFi Vault underlying token to any given token + * @param _l the zap layout struct + * @param _token the address of the input token + * @param _amount input token amount to deposit + * @param _permitParams ERC2612 permit params + * @param _zapParams the zapParams for the zap to be performed + * @return receivedAmount amount of output tokens received + */ + function _zapOut( + ZapStorage.Layout storage _l, + address _token, + uint256 _amount, + bytes memory _permitParams, + DataTypes.ZapData memory _zapParams + ) internal returns (uint256 receivedAmount) { + address vaultUnderlyingToken = _underlying(_zapParams.vault); + if (vaultUnderlyingToken == _token) { + revert Errors.InvalidToken(); + } + + _permit(_zapParams.vault, _permitParams); + ISwapper swapper = _getSwapper(); + + IERC20(_zapParams.vault).safeTransferFrom(msg.sender, address(this), _amount); + uint256 swapAmount = IVault(_zapParams.vault).userWithdrawVault( + address(this), + _amount, + _l.merkleProofs[_zapParams.vault] + ); + + _approveTokenIfNeeded(vaultUnderlyingToken, swapper.tokenTransferProxy(), swapAmount); + + SwapTypes.SwapData memory swapData = SwapTypes.SwapData( + vaultUnderlyingToken, + _token, + swapAmount, + _zapParams.toAmount, + _zapParams.callees, + _zapParams.exchangeData, + _zapParams.startIndexes, + _zapParams.values, + payable(msg.sender), + _zapParams.permit, + _zapParams.deadline + ); + + uint256 unused; + (receivedAmount, unused) = swapper.swap(swapData); + if (unused > 0) { + IERC20(_token).safeTransfer(msg.sender, unused); + } + } + + /** + * @dev function to call token permit method of extended ERC20 + * @param _token address of the token to call + * @param _permitParams raw data of the call `permit` of the token + */ + function _permit(address _token, bytes memory _permitParams) internal { + if (_permitParams.length == 32 * 7) { + (bool success, ) = _token.call(abi.encodePacked(IERC2612.permit.selector, _permitParams)); + if (!success) { + revert Errors.PermitFailed(); + } + } + } + + /** + * @dev set swapper address + * @param _l the zap layout struct + * @param _swapper swapper address + */ + function _setSwapper(ZapStorage.Layout storage _l, address _swapper) internal { + _l.swapper = ISwapper(_swapper); + } + + /** + * @dev set the zap contract merkle proof to deposit and withdraw on vault + * @param _l the zap layout struct + * @param _vault address of the target vault + * @param _merkleProof zapper contract merkle proof + */ + function _setMerkleProof( + ZapStorage.Layout storage _l, + address _vault, + bytes32[] memory _merkleProof + ) internal { + _l.merkleProofs[_vault] = _merkleProof; + } + + /** + * @dev function to get the underlying token of the OptyFi Vault + */ + function _underlying(address _vault) internal returns (address) { + return IVault(_vault).underlyingToken(); + } + + /** + * @dev get swapper address + */ + function _getSwapper() internal view returns (ISwapper swapper) { + swapper = ZapStorage.layout().swapper; + } + + /** + * @dev get merkle proof + */ + function _getMerkleProof(address _vault) internal view returns (bytes32[] memory _merkleProof) { + _merkleProof = ZapStorage.layout().merkleProofs[_vault]; + } + + /** + * @dev function to increase allowance + * @param _token address of the token to call + * @param _spender address of the spender + * @param _amount transfer amount + */ + function _approveTokenIfNeeded( + address _token, + address _spender, + uint256 _amount + ) private { + if (IERC20(_token).allowance(address(this), _spender) < _amount) { + IERC20(_token).safeApprove(_spender, type(uint256).max); + } + } +} diff --git a/contracts/protocol/zap/ZapStorage.sol b/contracts/protocol/zap/ZapStorage.sol new file mode 100644 index 000000000..e85793cc2 --- /dev/null +++ b/contracts/protocol/zap/ZapStorage.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { ISwapper } from "../optyfi-swapper/contracts/swap/ISwapper.sol"; + +/** + * @title Storage for OptyFiZapper + * @author OptyFi + */ +library ZapStorage { + bytes32 internal constant STORAGE_SLOT = keccak256("optyfi.contracts.storage.Zap"); + + struct Layout { + ISwapper swapper; + mapping(address => bytes32[]) merkleProofs; + } + + /** + * @notice return the layout struct stored at STORAGE_SLOT + * @return l the layout struct + */ + function layout() internal pure returns (Layout storage l) { + bytes32 slot = STORAGE_SLOT; + assembly { + l.slot := slot + } + } +} diff --git a/contracts/protocol/zap/ZapView.sol b/contracts/protocol/zap/ZapView.sol new file mode 100644 index 000000000..4b4cec638 --- /dev/null +++ b/contracts/protocol/zap/ZapView.sol @@ -0,0 +1,26 @@ +//SPDX-license-identifier: MIT +pragma solidity ^0.8.15; + +import { IZapView } from "./IZapView.sol"; +import { ISwapper } from "../optyfi-swapper/contracts/swap/ISwapper.sol"; +import { ZapInternal } from "./ZapInternal.sol"; + +/** + * @title ZapView + * @author OptyFi + */ +contract ZapView is IZapView, ZapInternal { + /** + * @inheritdoc IZapView + */ + function getSwapper() external view returns (ISwapper) { + return _getSwapper(); + } + + /** + * @inheritdoc IZapView + */ + function getMerkleProof(address _vault) external view returns (bytes32[] memory) { + return _getMerkleProof(_vault); + } +} diff --git a/contracts/protocol/zap/interfaces/IVault.sol b/contracts/protocol/zap/interfaces/IVault.sol new file mode 100644 index 000000000..797acc965 --- /dev/null +++ b/contracts/protocol/zap/interfaces/IVault.sol @@ -0,0 +1,38 @@ +//SPDX-license-identifier: MIT +pragma solidity ^0.8.15; + +interface IVault { + /** + * @notice Deposit underlying tokens to the vault + * @dev Mint the shares right away as per oracle based price per full share value + * @param _beneficiary the address of the deposit beneficiary, + * if _beneficiary = address(0) => _beneficiary = msg.sender + * @param _userDepositUT Amount in underlying token + * @param _permitParams permit parameters: amount, deadline, v, s, r + * @param _accountsProof merkle proof for caller + */ + function userDepositVault( + address _beneficiary, + uint256 _userDepositUT, + bytes calldata _permitParams, + bytes32[] calldata _accountsProof + ) external returns (uint256); + + /** + * @notice redeems the vault shares and transfers underlying token to `_beneficiary` + * @dev Burn the shares right away as per oracle based price per full share value + * @param _receiver the address which will receive the underlying tokens + * @param _userWithdrawVT amount in vault token + * @param _accountsProof merkle proof for caller + */ + function userWithdrawVault( + address _receiver, + uint256 _userWithdrawVT, + bytes32[] calldata _accountsProof + ) external returns (uint256); + + /** + * @dev the vault underlying token contract address + */ + function underlyingToken() external returns (address); +} diff --git a/contracts/protocol/zap/lib/DataTypes.sol b/contracts/protocol/zap/lib/DataTypes.sol new file mode 100644 index 000000000..ad016b203 --- /dev/null +++ b/contracts/protocol/zap/lib/DataTypes.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +/** + * @title Library for custom structs for OptyFiZapper + * @author OptyFi + */ +library DataTypes { + /** + * @param vault address of the vault + * @param toAmount amount of toToken to receive + * @param callees array of addresses to call (DEX and token addresses) + * @param exchangeData calldata to execute on callees + * @param startIndexes the index of the beginning of each call in exchangeData + * @param values array of encoded values for each call in exchangeData + * @param permit ERC2612 permit + * @param deadline timestamp until which swap may be fulfilled + */ + struct ZapData { + address vault; + uint256 toAmount; + uint256 deadline; + bytes exchangeData; + bytes permit; + address[] callees; + uint256[] startIndexes; + uint256[] values; + } +} diff --git a/contracts/protocol/zap/lib/Errors.sol b/contracts/protocol/zap/lib/Errors.sol new file mode 100644 index 000000000..e073ae818 --- /dev/null +++ b/contracts/protocol/zap/lib/Errors.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +/** + * @title Custom errors for OptyFiZapper + * @author OptyFi + */ +library Errors { + /** + * @notice permit call failed + */ + error PermitFailed(); + + /** + * @notice input or output token is equal to vault's underlying token + */ + error InvalidToken(); +} diff --git a/helpers/type.d.ts b/helpers/type.d.ts index 89a4055b2..5caadabf5 100644 --- a/helpers/type.d.ts +++ b/helpers/type.d.ts @@ -220,3 +220,14 @@ export type PoolRate = { pool: string; rate: number; }; + +export type ZapData = { + vault: string; + toAmount: BigNumberish; + deadline: BigNumberish; + exchangeData: string; + permit: string; + callees: string[]; + startIndexes: BigNumberish[]; + values: BigNumberish[]; +}; diff --git a/package.json b/package.json index b99fe8a14..47f3f77ce 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "@openzeppelin/contracts": "3.4.0", "@openzeppelin/contracts-0.8.x": "npm:@openzeppelin/contracts@4.4.1", "@optyfi/defi-legos": "v0.1.0-rc.38", + "@solidstate/contracts": "^0.0.35", "@uniswap/v2-core": "^1.0.1", "@uniswap/v2-periphery": "^1.1.0-beta.0" } diff --git a/spec/optyfi-zapper.behaviour.ts b/spec/optyfi-zapper.behaviour.ts new file mode 100644 index 000000000..6433221bf --- /dev/null +++ b/spec/optyfi-zapper.behaviour.ts @@ -0,0 +1,557 @@ +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signers"; +import { BigNumber } from "ethers"; +import hre, { deployments } from "hardhat"; +import { + ERC20, + ERC20Permit, + ERC20Permit__factory, + IUniswapV2Router02, + IZap, + IZapView, + IZapView__factory, + Vault, +} from "../typechain"; +import { ZapData } from "../helpers/type"; +import { expect } from "chai"; +import { ESSENTIAL_CONTRACTS } from "../helpers/constants/essential-contracts-name"; +import { getAccountsMerkleProof, getAccountsMerkleRoot } from "../helpers/utils"; +import { getPermitSignature } from "../test/test-opty/utils"; + +export function describeBehaviorOfOptyFiZapper(deploy: () => Promise) { + const ethers = hre.ethers; + let owner: SignerWithAddress; + let maker: SignerWithAddress; + let UNIWhale: SignerWithAddress; + let USDCWhale: SignerWithAddress; + + let instance: IZap; + let USDCERC20: ERC20Permit; + let WETHERC20: ERC20; + let UNIERC20: ERC20; + let uniRouter: IUniswapV2Router02; + let usdcVault: Vault; + let wethVault: Vault; + let zapData: ZapData; + let zapView: IZapView; + + let snapshotId: number; + let usdcZapOutAmount: BigNumber; + let OPUSDCSAVE_VAULT_ADDRESS: string; + let OPWETHEARN_VAULT_ADDRESS: string; + let SWAPPER_ADDRESS: string; + + //EOA mainnet + const UNIWhaleAddress = "0x7d2d43e63666f45b40316b44212325625dbaeb40"; + const USDCWhaleAddress = "0xc4153b9b781789e899dee4e8b0d1784a81df3cc4"; + + //tokens mainnet + const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; + const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + const UNI = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984"; + + //contracts mainnet + const UniswapV2Router02Address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"; + + const swapDeadline = BigNumber.from("1000000000000000000000000000000000000"); + const uniSwapAmount = ethers.utils.parseUnits("100", 18); + const usdcSwapAmount = ethers.utils.parseUnits("100000", 6); + const ethSwapAmount = ethers.utils.parseEther("10"); + + describe("::Zap", () => { + before(async () => { + // USDC Vault + OPUSDCSAVE_VAULT_ADDRESS = (await deployments.get("opUSDCsave")).address; + usdcVault = await ethers.getContractAt(ESSENTIAL_CONTRACTS.VAULT, OPUSDCSAVE_VAULT_ADDRESS); + + // WETH Vault + OPWETHEARN_VAULT_ADDRESS = (await deployments.get("opWETHearn")).address; + wethVault = await ethers.getContractAt(ESSENTIAL_CONTRACTS.VAULT, OPWETHEARN_VAULT_ADDRESS); + + [owner, maker] = await ethers.getSigners(); + + // (0-15) Deposit fee UT = 0 UT = 0000 + // (16-31) Deposit fee % = 0% = 0000 + // (32-47) Withdrawal fee UT = 0 UT = 0000 + // (48-63) Withdrawal fee % = 0% = 0000 + // (64-79) Max vault value jump % = 1% = 0064 + // (80-239) vault fee address = 0000000000000000000000000000000000000000 + // (240-247) risk profile code = 1 = 01 + // (248) emergency shutdown = false = 0 + // (249) unpause = true = 1 + // (250) allow whitelisted state = true = 1 + // (251) - 0 + // (252) - 0 + // (253) - 0 + // (254) - 0 + // (255) - 0 + // 0x0601000000000000000000000000000000000000000000640000000000000000 + // 1811018241397843937822879938261491478723170994297509433919320763695890432000 + await usdcVault + .connect(owner) + .setVaultConfiguration("2715643938564376714569528258641865758826842749497826340477583138757711757312"); + + await wethVault + .connect(owner) + .setVaultConfiguration("2715643938564376714569528258641865758826842749497826340477583138757711757312"); + + await usdcVault.connect(owner).setUnpaused(true); + await usdcVault.connect(owner).setValueControlParams( + "1000000000000", // userDepositCapUT: 1,000,000 USDC + "0", // minimumDepositValueUT: 0 USDC + "1000000000000000", // totalValueLockedLimitUT: 1,000,000,000 USDC + ); + + await wethVault.connect(owner).setUnpaused(true); + await wethVault.connect(owner).setValueControlParams( + "100000000000000000000", // userDepositCapUT: 100 WETH + "0", // minimumDepositValueUT: 0 USDC + "1000000000000000000000000", // totalValueLockedLimitUT: 1,000,000 USDC + ); + + await hre.network.provider.request({ + method: "hardhat_impersonateAccount", + params: [UNIWhaleAddress], + }); + + await hre.network.provider.request({ + method: "hardhat_impersonateAccount", + params: [USDCWhaleAddress], + }); + + UNIWhale = await ethers.getSigner(ethers.utils.getAddress(UNIWhaleAddress)); + USDCWhale = await ethers.getSigner(ethers.utils.getAddress(USDCWhaleAddress)); + uniRouter = await ethers.getContractAt("IUniswapV2Router02", UniswapV2Router02Address); + USDCERC20 = await ethers.getContractAt(ERC20Permit__factory.abi, USDC); + WETHERC20 = await ethers.getContractAt(ESSENTIAL_CONTRACTS.ERC20, WETH); + UNIERC20 = await ethers.getContractAt(ESSENTIAL_CONTRACTS.ERC20, UNI); + }); + + beforeEach(async () => { + let tx; + instance = await deploy(); + zapView = await ethers.getContractAt(IZapView__factory.abi, instance.address); + SWAPPER_ADDRESS = await zapView.getSwapper(); + snapshotId = await ethers.provider.send("evm_snapshot", []); + + //whitelist zap account + const _accountRoot = getAccountsMerkleRoot([instance.address, owner.address]); + await usdcVault.setWhitelistedAccountsRoot(_accountRoot); + await wethVault.setWhitelistedAccountsRoot(_accountRoot); + + //provide UNI whale with ETH to make required transactions + tx = await maker.sendTransaction({ + to: UNIWhaleAddress, + value: ethers.utils.parseEther("0.1"), + gasLimit: 10000000, + }); + await tx.wait(); + + tx = await UNIERC20.connect(UNIWhale).transfer(maker.address, ethers.utils.parseUnits("100000", 18)); + await tx.wait(); + + //provide USDC whale with ETH to make required transactions + tx = await maker.sendTransaction({ + to: USDCWhaleAddress, + value: ethers.utils.parseEther("0.1"), + gasLimit: 10000000, + }); + await tx.wait(); + + tx = await USDCERC20.connect(USDCWhale).transfer(maker.address, ethers.utils.parseUnits("1000000", 6)); + await tx.wait(); + }); + + afterEach(async () => { + await ethers.provider.send("evm_revert", [snapshotId]); + }); + + describe("#setMerkleProof(address,bytes32[])", () => { + it("sets the vault merkle proof correctly", async () => { + const _proof = getAccountsMerkleProof([instance.address, owner.address], instance.address); + + await instance.connect(owner).setMerkleProof(usdcVault.address, _proof); + expect(await zapView.getMerkleProof(usdcVault.address)).to.eql(_proof); + await instance.connect(owner).setMerkleProof(wethVault.address, _proof); + expect(await zapView.getMerkleProof(wethVault.address)).to.eql(_proof); + }); + }); + + describe("#setSwapper(address)", () => { + it("sets swapper correctly", async () => { + await instance.connect(owner).setSwapper(SWAPPER_ADDRESS); + expect(await zapView.getSwapper()).to.eql(SWAPPER_ADDRESS); + }); + }); + + describe("#ZapIn(address,uint256,struct(ZapData))", () => { + describe("UNI => opUSDCsave (using approve)", () => { + beforeEach(async () => { + await UNIERC20.connect(maker).approve(instance.address, uniSwapAmount); + + //calculate call datas for swap + const uniswapData = uniRouter.interface.encodeFunctionData("swapExactTokensForTokens", [ + uniSwapAmount, + ethers.constants.Zero, + [UNI, USDC], + SWAPPER_ADDRESS, + swapDeadline, + ]); + const approveData = UNIERC20.interface.encodeFunctionData("approve", [ + uniRouter.address, + uniSwapAmount.mul(2), + ]); + + //construct swapData + const calls: string[] = [approveData, uniswapData]; + const startIndexes = ["0"]; + let exchangeData = `0x`; + for (const i in calls) { + startIndexes.push(startIndexes[i] + calls[i].substring(2).length / 2); + exchangeData = exchangeData.concat(calls[i].substring(2)); + } + + zapData = { + vault: OPUSDCSAVE_VAULT_ADDRESS, + toAmount: ethers.constants.One, + deadline: swapDeadline, + exchangeData: exchangeData, + permit: "0x", + callees: [UNIERC20.address, uniRouter.address], + startIndexes: startIndexes, + values: [ethers.constants.Zero, ethers.constants.Zero], + }; + + const _proof = getAccountsMerkleProof([instance.address, owner.address], instance.address); + await instance.connect(owner).setMerkleProof(usdcVault.address, _proof); + }); + + it("vault emits Transfer event", async () => { + const expectedReturns = await uniRouter.getAmountsOut(uniSwapAmount, [UNI, USDC]); + + //zap UNI -> opUSDCsave + await expect(instance.connect(maker).zapIn(UNI, uniSwapAmount, "0x", zapData)) + .to.emit(usdcVault, "Transfer") + .withArgs(ethers.constants.AddressZero, maker.address, expectedReturns[1]); + }); + + it("increases opVault token balance by zapped amount", async () => { + const expectedReturns = await uniRouter.getAmountsOut(uniSwapAmount, [UNI, USDC]); + + const _previousBalance = await usdcVault.balanceOf(maker.address); + const _previousVaultBalance = await usdcVault.balanceUT(); + + //zap UNI -> opUSDCsave + await expect(instance.connect(maker).zapIn(UNI, uniSwapAmount, "0x", zapData)) + .to.emit(usdcVault, "Transfer") + .withArgs(ethers.constants.AddressZero, maker.address, expectedReturns[1]); + expect(await USDCERC20.balanceOf(usdcVault.address)).to.eq(_previousVaultBalance.add(expectedReturns[1])); + expect(await usdcVault.totalSupply()).to.eq(_previousVaultBalance.add(expectedReturns[1])); + expect(await usdcVault.totalDeposits(maker.address)).to.eq(_previousBalance.add(expectedReturns[1])); + // the vault shares VT will be same as total supply is zero + expect(await usdcVault.balanceOf(maker.address)).to.eq(_previousBalance.add(expectedReturns[1])); + }); + }); + + describe("USDC => opWETHsave (using permit)", () => { + beforeEach(async () => { + //calculate call datas for swap + const uniswapData = uniRouter.interface.encodeFunctionData("swapExactTokensForTokens", [ + usdcSwapAmount, + ethers.constants.Zero, + [USDC, WETH], + SWAPPER_ADDRESS, + swapDeadline, + ]); + const approveData = USDCERC20.interface.encodeFunctionData("approve", [ + uniRouter.address, + usdcSwapAmount.mul(2), + ]); + + //construct swapData + const calls: string[] = [approveData, uniswapData]; + const startIndexes = ["0"]; + let exchangeData = `0x`; + for (const i in calls) { + startIndexes.push(startIndexes[i] + calls[i].substring(2).length / 2); + exchangeData = exchangeData.concat(calls[i].substring(2)); + } + + zapData = { + vault: OPWETHEARN_VAULT_ADDRESS, + toAmount: ethers.constants.One, + deadline: swapDeadline, + exchangeData: exchangeData, + permit: "0x", + callees: [USDCERC20.address, uniRouter.address], + startIndexes: startIndexes, + values: [ethers.constants.Zero, ethers.constants.Zero], + }; + + const _proof = getAccountsMerkleProof([instance.address, owner.address], instance.address); + await instance.connect(owner).setMerkleProof(wethVault.address, _proof); + }); + + it("vault emits Transfer event", async () => { + const deadline = ethers.constants.MaxUint256; + const { v, r, s } = await getPermitSignature(maker, USDCERC20, instance.address, usdcSwapAmount, deadline, { + version: "2", + }); + const dataPermit = ethers.utils.defaultAbiCoder.encode( + ["address", "address", "uint256", "uint256", "uint8", "bytes32", "bytes32"], + [maker.address, instance.address, usdcSwapAmount, deadline, v, r, s], + ); + + const expectedReturns = await uniRouter.getAmountsOut(usdcSwapAmount, [USDC, WETH]); + //zap UNI -> opUSDCsave + await expect(instance.connect(maker).zapIn(USDC, usdcSwapAmount, dataPermit, zapData)) + .to.emit(wethVault, "Transfer") + .withArgs(ethers.constants.AddressZero, maker.address, expectedReturns[1]); + }); + + it("increases opVault token balance by zapped amount", async () => { + const deadline = ethers.constants.MaxUint256; + const { v, r, s } = await getPermitSignature(maker, USDCERC20, instance.address, usdcSwapAmount, deadline, { + version: "2", + }); + const dataPermit = ethers.utils.defaultAbiCoder.encode( + ["address", "address", "uint256", "uint256", "uint8", "bytes32", "bytes32"], + [maker.address, instance.address, usdcSwapAmount, deadline, v, r, s], + ); + + const expectedReturns = await uniRouter.getAmountsOut(usdcSwapAmount, [USDC, WETH]); + const _previousBalance = await wethVault.balanceOf(maker.address); + const _previousVaultBalance = await wethVault.balanceUT(); + //zap UNI -> opUSDCsave + await instance.connect(maker).zapIn(USDC, usdcSwapAmount, dataPermit, zapData); + expect(await WETHERC20.balanceOf(wethVault.address)).to.eq(_previousVaultBalance.add(expectedReturns[1])); + expect(await wethVault.totalSupply()).to.eq(_previousVaultBalance.add(expectedReturns[1])); + expect(await wethVault.totalDeposits(maker.address)).to.eq(_previousBalance.add(expectedReturns[1])); + // the vault shares VT will be same as total supply is zero + expect(await wethVault.balanceOf(maker.address)).to.eq(_previousBalance.add(expectedReturns[1])); + }); + }); + + describe("ETH => opUSDCsave", () => { + beforeEach(async () => { + //calculate call datas for swap + const uniswapData = uniRouter.interface.encodeFunctionData("swapExactETHForTokens", [ + ethers.constants.Zero, + [WETH, USDC], + SWAPPER_ADDRESS, + swapDeadline, + ]); + + //construct swapData + const calls: string[] = [uniswapData]; + const startIndexes = ["0"]; + let exchangeData = `0x`; + for (const i in calls) { + startIndexes.push(startIndexes[i] + calls[i].substring(2).length / 2); + exchangeData = exchangeData.concat(calls[i].substring(2)); + } + + zapData = { + vault: OPUSDCSAVE_VAULT_ADDRESS, + toAmount: ethers.constants.One, + deadline: swapDeadline, + exchangeData: exchangeData, + permit: "0x", + callees: [uniRouter.address], + startIndexes: startIndexes, + values: [ethSwapAmount], + }; + + const _proof = getAccountsMerkleProof([instance.address, owner.address], instance.address); + await instance.connect(owner).setMerkleProof(usdcVault.address, _proof); + }); + + it("vault emits Transfer event", async () => { + const expectedReturns = await uniRouter.getAmountsOut(ethSwapAmount, [WETH, USDC]); + + //zap ETH -> opUSDCsave + await expect(instance.connect(maker).zapIn(ETH, ethSwapAmount, "0x", zapData, { value: ethSwapAmount })) + .to.emit(usdcVault, "Transfer") + .withArgs(ethers.constants.AddressZero, maker.address, expectedReturns[1]); + }); + + it("increases opVault token balance by zapped amount", async () => { + const expectedReturns = await uniRouter.getAmountsOut(ethSwapAmount, [WETH, USDC]); + + const _previousBalance = await usdcVault.balanceOf(maker.address); + const _previousVaultBalance = await usdcVault.balanceUT(); + + //zap ETH -> opUSDCsave + await instance.connect(maker).zapIn(ETH, ethSwapAmount, "0x", zapData, { value: ethSwapAmount }); + expect(await USDCERC20.balanceOf(usdcVault.address)).to.eq(_previousVaultBalance.add(expectedReturns[1])); + expect(await usdcVault.totalSupply()).to.eq(_previousVaultBalance.add(expectedReturns[1])); + expect(await usdcVault.totalDeposits(maker.address)).to.eq(_previousBalance.add(expectedReturns[1])); + // the vault shares VT will be same as total supply is zero + expect(await usdcVault.balanceOf(maker.address)).to.eq(_previousBalance.add(expectedReturns[1])); + }); + }); + }); + + describe("#ZapOut(address,uint256,struct(ZapData))", () => { + beforeEach(async () => { + const uniswapData = uniRouter.interface.encodeFunctionData("swapExactETHForTokens", [ + ethers.constants.Zero, + [WETH, USDC], + SWAPPER_ADDRESS, + swapDeadline, + ]); + + //construct swapData + const calls: string[] = [uniswapData]; + const startIndexes = ["0"]; + let exchangeData = `0x`; + for (const i in calls) { + startIndexes.push(startIndexes[i] + calls[i].substring(2).length / 2); + exchangeData = exchangeData.concat(calls[i].substring(2)); + } + + zapData = { + vault: OPUSDCSAVE_VAULT_ADDRESS, + toAmount: ethers.constants.One, + deadline: swapDeadline, + exchangeData: exchangeData, + permit: "0x", + callees: [uniRouter.address], + startIndexes: startIndexes, + values: [ethSwapAmount], + }; + + const _proof = getAccountsMerkleProof([instance.address, owner.address], instance.address); + await instance.connect(owner).setMerkleProof(usdcVault.address, _proof); + await instance.connect(owner).setMerkleProof(wethVault.address, _proof); + //zap ETH -> opUSDCsave + await instance.connect(maker).zapIn(ETH, ethSwapAmount, "0x", zapData, { value: ethSwapAmount }); + }); + + describe("opUSDCsave => ETH", () => { + beforeEach(async () => { + usdcZapOutAmount = await usdcVault.balanceOf(maker.address); + + //calculate call datas for swap + const uniswapData = uniRouter.interface.encodeFunctionData("swapExactTokensForETH", [ + usdcZapOutAmount, + ethers.constants.Zero, + [USDC, WETH], + SWAPPER_ADDRESS, + swapDeadline, + ]); + + const approveData = USDCERC20.interface.encodeFunctionData("approve", [uniRouter.address, usdcZapOutAmount]); + + //construct swapData + const calls: string[] = [approveData, uniswapData]; + const startIndexes = ["0"]; + let exchangeData = `0x`; + for (const i in calls) { + startIndexes.push(startIndexes[i] + calls[i].substring(2).length / 2); + exchangeData = exchangeData.concat(calls[i].substring(2)); + } + + zapData = { + vault: OPUSDCSAVE_VAULT_ADDRESS, + toAmount: ethers.constants.One, + deadline: swapDeadline, + exchangeData: exchangeData, + permit: "0x", + callees: [USDCERC20.address, uniRouter.address], + startIndexes: startIndexes, + values: [ethers.constants.Zero, ethers.constants.Zero], + }; + }); + + it("increases ETH balance by zapped amount using permit", async () => { + const expectedReturns = await uniRouter.getAmountsOut(usdcZapOutAmount, [USDC, WETH]); + + const deadline = ethers.constants.MaxUint256; + const { v, r, s } = await getPermitSignature(maker, usdcVault, instance.address, usdcZapOutAmount, deadline); + const dataPermit = ethers.utils.defaultAbiCoder.encode( + ["address", "address", "uint256", "uint256", "uint8", "bytes32", "bytes32"], + [maker.address, instance.address, usdcZapOutAmount, deadline, v, r, s], + ); + //zap UNI -> opUSDCsave + await expect( + await instance.connect(maker).zapOut(ETH, usdcZapOutAmount, dataPermit, zapData), + ).to.changeEtherBalance(maker, expectedReturns[1]); + }); + + it("increases ETH balance by zapped amount using approve", async () => { + const expectedReturns = await uniRouter.getAmountsOut(usdcZapOutAmount, [USDC, WETH]); + + await usdcVault.connect(maker).approve(instance.address, usdcZapOutAmount); + //zap UNI -> opUSDCsave + await expect( + await instance.connect(maker).zapOut(ETH, usdcZapOutAmount, "0x", zapData), + ).to.changeEtherBalance(maker, expectedReturns[1]); + }); + }); + + describe("opUSDCsave => WETH", () => { + beforeEach(async () => { + usdcZapOutAmount = await usdcVault.balanceOf(maker.address); + + //calculate call datas for swap + const uniswapData = uniRouter.interface.encodeFunctionData("swapExactTokensForTokens", [ + usdcZapOutAmount, + ethers.constants.Zero, + [USDC, WETH], + SWAPPER_ADDRESS, + swapDeadline, + ]); + const approveData = USDCERC20.interface.encodeFunctionData("approve", [uniRouter.address, usdcZapOutAmount]); + + //construct swapData + const calls: string[] = [approveData, uniswapData]; + const startIndexes = ["0"]; + let exchangeData = `0x`; + for (const i in calls) { + startIndexes.push(startIndexes[i] + calls[i].substring(2).length / 2); + exchangeData = exchangeData.concat(calls[i].substring(2)); + } + + zapData = { + vault: OPUSDCSAVE_VAULT_ADDRESS, + toAmount: ethers.constants.One, + deadline: swapDeadline, + exchangeData: exchangeData, + permit: "0x", + callees: [USDCERC20.address, uniRouter.address], + startIndexes: startIndexes, + values: [ethers.constants.Zero, ethers.constants.Zero], + }; + }); + + it("increases token balance by zapped amount using permit", async () => { + const expectedReturns = await uniRouter.getAmountsOut(usdcZapOutAmount, [USDC, WETH]); + + const deadline = ethers.constants.MaxUint256; + const { v, r, s } = await getPermitSignature(maker, usdcVault, instance.address, usdcZapOutAmount, deadline); + const dataPermit = ethers.utils.defaultAbiCoder.encode( + ["address", "address", "uint256", "uint256", "uint8", "bytes32", "bytes32"], + [maker.address, instance.address, usdcZapOutAmount, deadline, v, r, s], + ); + + // zap opUSDCsave => WETH + await expect(() => + instance.connect(maker).zapOut(WETH, usdcZapOutAmount, dataPermit, zapData), + ).to.changeTokenBalance(WETHERC20, maker, expectedReturns[1]); + }); + + it("increases token balance by zapped amount using approve", async () => { + const expectedReturns = await uniRouter.getAmountsOut(usdcZapOutAmount, [USDC, WETH]); + + await usdcVault.connect(maker).approve(instance.address, usdcZapOutAmount); + + // zap opUSDCsave => WETH + await expect(() => + instance.connect(maker).zapOut(WETH, usdcZapOutAmount, "0x", zapData), + ).to.changeTokenBalance(WETHERC20, maker, expectedReturns[1]); + }); + }); + }); + }); +} diff --git a/test/test-opty/optyfi-zapper.spec.ts b/test/test-opty/optyfi-zapper.spec.ts new file mode 100644 index 000000000..ff161ebe6 --- /dev/null +++ b/test/test-opty/optyfi-zapper.spec.ts @@ -0,0 +1,74 @@ +import hre, { deployments } from "hardhat"; +import { describeBehaviorOfOptyFiZapper } from "../../spec/optyfi-zapper.behaviour"; +import { + OptyFiSwapper, + OptyFiSwapper__factory, + SwapView__factory, + Swap__factory, + OptyFiZapper__factory, + OptyFiZapper, + Zap__factory, + ZapView__factory, + IZap, +} from "../../typechain"; + +describe("::OptyFiZapper Contracts", () => { + const ethers = hre.ethers; + + let deployer: any; + let snapshotId: number; + + let OptyFiSwapper: OptyFiSwapper; + let OptyFiZapper: OptyFiZapper; + let instance: IZap; + + before(async () => { + await deployments.fixture(); + [deployer] = await ethers.getSigners(); + + OptyFiSwapper = await new OptyFiSwapper__factory(deployer).deploy(); + const swapperSelectors = new Set(); + const swapperFacetCuts = [ + await new Swap__factory(deployer).deploy(), + await new SwapView__factory(deployer).deploy(), + ].map(function (f) { + return { + target: f.address, + action: 0, + selectors: Object.keys(f.interface.functions) + .filter(fn => !swapperSelectors.has(fn) && swapperSelectors.add(fn)) + .map(fn => f.interface.getSighash(fn)), + }; + }); + + await OptyFiSwapper.diamondCut(swapperFacetCuts, ethers.constants.AddressZero, "0x"); + + OptyFiZapper = await new OptyFiZapper__factory(deployer).deploy(OptyFiSwapper.address); + const zapperSelectors = new Set(); + const zapperFacetCuts = [ + await new Zap__factory(deployer).deploy(), + await new ZapView__factory(deployer).deploy(), + ].map(function (f) { + return { + target: f.address, + action: 0, + selectors: Object.keys(f.interface.functions) + .filter(fn => !zapperSelectors.has(fn) && zapperSelectors.add(fn)) + .map(fn => f.interface.getSighash(fn)), + }; + }); + + await OptyFiZapper.diamondCut(zapperFacetCuts, ethers.constants.AddressZero, "0x"); + instance = Zap__factory.connect(OptyFiZapper.address, deployer); + }); + + beforeEach(async () => { + snapshotId = await ethers.provider.send("evm_snapshot", []); + }); + + afterEach(async () => { + await ethers.provider.send("evm_revert", [snapshotId]); + }); + + describeBehaviorOfOptyFiZapper(async () => instance); +}); diff --git a/yarn.lock b/yarn.lock index 7c95dab4a..2419cfced 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1962,6 +1962,13 @@ __metadata: languageName: node linkType: hard +"@solidstate/contracts@npm:^0.0.35": + version: 0.0.35 + resolution: "@solidstate/contracts@npm:0.0.35" + checksum: db0a88d6c01dd99f40cc8074d3df429d8485fa997eb38171c18464253a4d7512b2f04c925efd0ef2373691a875c063c52cc7f382232995f67984fd4fc7cb9974 + languageName: node + linkType: hard + "@szmarczak/http-timer@npm:^1.1.2": version: 1.1.2 resolution: "@szmarczak/http-timer@npm:1.1.2" @@ -6247,6 +6254,7 @@ __metadata: "@openzeppelin/contracts": 3.4.0 "@openzeppelin/contracts-0.8.x": "npm:@openzeppelin/contracts@4.4.1" "@optyfi/defi-legos": v0.1.0-rc.38 + "@solidstate/contracts": ^0.0.35 "@tenderly/hardhat-tenderly": 1.1.0-beta.6 "@typechain/ethers-v5": ^7.1.2 "@typechain/hardhat": ^2.3.0