diff --git a/packages/ovault-composer-evm/.gitignore b/packages/ovault-composer-evm/.gitignore new file mode 100644 index 0000000000..04fccb94b0 --- /dev/null +++ b/packages/ovault-composer-evm/.gitignore @@ -0,0 +1,13 @@ +out +cache + +# artifacts; ignore all files except the local contract artifacts. +artifacts/* +# !artifacts/Fee.sol/ +# !artifacts/IFee.sol/ +# !artifacts/IOFT.sol/ +# !artifacts/OFTComposeMsgCodec.sol/ +# !artifacts/OFTMsgCodec.sol/ +# !artifacts/OFT.sol/ +# !artifacts/OFTAdapter.sol/ +# !artifacts/OFTCore.sol/ \ No newline at end of file diff --git a/packages/ovault-composer-evm/README.md b/packages/ovault-composer-evm/README.md new file mode 100644 index 0000000000..e67d0c0b88 --- /dev/null +++ b/packages/ovault-composer-evm/README.md @@ -0,0 +1,31 @@ +

+ + LayerZero + +

+ +

@layerzerolabs/ovault-composer

+ + +

+ + NPM Version + + Downloads + + NPM License +

+ +## Installation + +```bash +pnpm install @layerzerolabs/ovault-composer +``` + +```bash +yarn install @layerzerolabs/ovault-composer +``` + +```bash +npm install @layerzerolabs/ovault-composer +``` diff --git a/packages/ovault-composer-evm/contracts/OVault.sol b/packages/ovault-composer-evm/contracts/OVault.sol new file mode 100644 index 0000000000..befcaae49b --- /dev/null +++ b/packages/ovault-composer-evm/contracts/OVault.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract OVault is ERC4626 { + using Math for uint256; + using SafeERC20 for IERC20; + + constructor(string memory name, string memory symbol, address asset) ERC4626(IERC20(asset)) ERC20(name, symbol) {} + + /// @dev Using solmate's implementation to work around rounding issues on initial minting + function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) { + uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. + + return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), rounding); + } + + /// @dev Using solmate's implementation to work around rounding issues on initial minting + function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view override returns (uint256) { + uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. + + return supply == 0 ? shares : shares.mulDiv(totalAssets(), supply, rounding); + } +} diff --git a/packages/ovault-composer-evm/contracts/OVaultComposer.sol b/packages/ovault-composer-evm/contracts/OVaultComposer.sol new file mode 100644 index 0000000000..2f56ac7931 --- /dev/null +++ b/packages/ovault-composer-evm/contracts/OVaultComposer.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +import { IOFT, SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; +import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol"; +import { ILayerZeroEndpointV2 } from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroEndpointV2.sol"; +import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; + +import { IOVaultComposer, FailedMessage, FailedState } from "./interfaces/IOVaultComposer.sol"; + +contract OVaultComposer is IOVaultComposer, ReentrancyGuard { + using OFTComposeMsgCodec for bytes; + using OFTComposeMsgCodec for bytes32; + + address public immutable ASSET_OFT; // any OFT + address public immutable SHARE_OFT; // lockbox adapter + IERC4626 public immutable OVAULT; // IERC4626 + address public immutable ENDPOINT; + uint32 public immutable HUB_EID; + + mapping(bytes32 guid => FailedMessage) public failedMessages; + + constructor(address _ovault, address _asset, address _share) { + OVAULT = IERC4626(_ovault); + ASSET_OFT = _asset; + SHARE_OFT = _share; + + if (!IOFT(_share).approvalRequired()) { + revert ShareOFTShouldBeLockboxAdapter(address(_share)); + } + + ENDPOINT = address(IOAppCore(ASSET_OFT).endpoint()); + HUB_EID = ILayerZeroEndpointV2(ENDPOINT).eid(); + + // Approve the adapter to spend the share tokens held by this contract + IERC20(IOFT(_share).token()).approve(address(_ovault), type(uint256).max); + IERC20(IOFT(_share).token()).approve(_share, type(uint256).max); + IERC20(IOFT(_asset).token()).approve(address(_ovault), type(uint256).max); + } + + function lzCompose( + address _refundOFT, + bytes32 _guid, + bytes calldata _message, + address /*_executor*/, + bytes calldata /*_extraData*/ + ) external payable virtual override { + if (msg.sender != ENDPOINT) revert OnlyEndpoint(msg.sender); + if (_refundOFT != ASSET_OFT && _refundOFT != SHARE_OFT) revert OnlyOFT(_refundOFT); + + /// @dev Route to the correct target OFT + address oft = _refundOFT == ASSET_OFT ? SHARE_OFT : ASSET_OFT; + + /// @dev Extracted from the _message header. Will always be part of the _message since it is created by lzReceive + uint256 amount = OFTComposeMsgCodec.amountLD(_message); + bytes memory sendParamEncoded = OFTComposeMsgCodec.composeMsg(_message); + + SendParam memory refundSendParam; + refundSendParam.dstEid = OFTComposeMsgCodec.srcEid(_message); + refundSendParam.to = OFTComposeMsgCodec.composeFrom(_message); + refundSendParam.amountLD = amount; + + SendParam memory sendParam; + + /// @dev Try decoding the composeMsg as a SendParam + try this.decodeSendParam(sendParamEncoded) returns (SendParam memory sendParamDecoded) { + /// @dev In the case of a valid decode we have the raw SendParam to be forwarded to the target OFT (oft) + sendParam = sendParamDecoded; + sendParam.amountLD = 0; + } catch { + /// @dev In the case of a failed decode we store the failed message and emit an event. + /// @dev This message can only be refunded back to the source chain. + failedMessages[_guid] = FailedMessage(address(0), sendParam, _refundOFT, refundSendParam); + emit DecodeFailed(_guid, _refundOFT, sendParamEncoded); + return; + } + + /// @dev Try to early catch ONLY when the target OFT does not have a peer set for the destination chain. + if (_isInvalidPeer(oft, sendParam.dstEid)) { + failedMessages[_guid] = FailedMessage(address(0), sendParam, _refundOFT, refundSendParam); + emit NoPeer(_guid, oft, sendParam.dstEid); + return; + } + + /// @dev Try to execute the action on the target OFT. If we hit an issue then it rolls back the storage changes. + try this.executeOVaultAction(_refundOFT, amount, sendParam) returns (uint256 vaultAmount) { + sendParam.amountLD = vaultAmount; + } catch (bytes memory errMsg) { + failedMessages[_guid] = FailedMessage(oft, sendParam, _refundOFT, refundSendParam); + emit GenericError(_guid, oft, errMsg); + return; + } + + /// @dev Try sending the message to the target OFT + try this.send{ value: msg.value }(oft, sendParam) { + emit Sent(_guid, oft); + } catch { + /// @dev A failed send can happen due to not enough msg.value + /// @dev Since we have the target tokens in the composer, we can retry with more gas. + failedMessages[_guid] = FailedMessage(oft, sendParam, address(0), refundSendParam); + emit SendFailed(_guid, oft); + return; + } + } + + /// @dev External call for try...catch logic in lzCompose() + function decodeSendParam(bytes calldata sendParamBytes) external pure returns (SendParam memory sendParam) { + sendParam = abi.decode(sendParamBytes, (SendParam)); + } + + /// @dev External call for try...catch logic in lzCompose() + function executeOVaultAction( + address _oft, + uint256 _amount, + SendParam calldata _sendParam + ) external nonReentrant returns (uint256 vaultAmount) { + if (msg.sender != address(this)) revert OnlySelf(msg.sender); + vaultAmount = _executeOVaultAction(_oft, _amount); + if (vaultAmount < _sendParam.minAmountLD) { + /// @dev Will rollback on this function's storage changes (trade does not happen) + revert NotEnoughTargetTokens(vaultAmount, _sendParam.minAmountLD); + } + } + + /// @dev External call for try...catch logic in lzCompose() + function send(address _oft, SendParam calldata _sendParam) external payable nonReentrant { + if (msg.sender != address(this)) revert OnlySelf(msg.sender); + if (_sendParam.dstEid == HUB_EID) { + address _receiver = _sendParam.to.bytes32ToAddress(); + uint256 _amountLD = _sendParam.amountLD; + IERC20 token = IERC20(IOFT(_oft).token()); + token.transfer(_receiver, _amountLD); + if (msg.value > 0) { + (bool sent, ) = _receiver.call{ value: msg.value }(""); + require(sent, "Failed to send Ether"); + } + emit SentOnHub(_receiver, _oft, _amountLD); + return; + } + _send(_oft, _sendParam); + } + + /// @dev Permissionless function to send back the message to the source chain + /// @dev Always possible unless the lzCompose() fails due to an Out-Of-Gas panic + function refund(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { + FailedMessage memory failedMessage = failedMessages[_guid]; + SendParam memory refundSendParam = failedMessage.sendParam; + if (failedGuidState(_guid) != FailedState.CanOnlyRefund) revert CanNotRefund(_guid); + + refundSendParam.extraOptions = _extraOptions; + + delete failedMessages[_guid]; + _send(failedMessage.refundOFT, refundSendParam); + emit Refunded(_guid, failedMessage.refundOFT); + } + + /// @dev Permissionless function to retry the message with more gas + /// @dev Probabilistically possible if the OFT.send() fails - ex: invalid peer + function retry(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { + FailedMessage memory failedMessage = failedMessages[_guid]; + if (failedGuidState(_guid) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); + + SendParam memory sendParam = failedMessage.sendParam; + + sendParam.extraOptions = _extraOptions; + + delete failedMessages[_guid]; + _send(failedMessage.oft, sendParam); + emit Retried(_guid, failedMessage.oft); + } + + /// @dev Retry mechanism for transactions that failed due to slippage. This can revert. + function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable { + FailedMessage memory failedMessage = failedMessages[_guid]; + if (failedGuidState(_guid) != FailedState.CanRetryWithSwap) revert CanNotRetry(_guid); + + SendParam memory sendParam = failedMessage.sendParam; + sendParam.extraOptions = _extraOptions; + + uint256 amountLd = failedMessage.refundSendParam.amountLD; + + delete failedMessages[_guid]; + sendParam.amountLD = _executeOVaultAction(failedMessage.refundOFT, amountLd); + + _send(failedMessage.oft, sendParam); + emit Sent(_guid, failedMessage.oft); + } + + /// @dev Internal function to send the message to the target OFT + function _send(address _oft, SendParam memory _sendParam) internal { + IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); + } + + function _executeOVaultAction(address _oft, uint256 _amount) internal returns (uint256 vaultAmount) { + if (_oft == address(ASSET_OFT)) { + vaultAmount = OVAULT.deposit(_amount, address(this)); + } else { + vaultAmount = OVAULT.redeem(_amount, address(this), address(this)); + } + } + + /// @dev Helper to check if the target OFT does not have a peer set for the destination chain OR if our target chain is the not the same as the HUB chain + function _isInvalidPeer(address _oft, uint32 _dstEid) internal view returns (bool) { + return _dstEid != HUB_EID && IOAppCore(_oft).peers(_dstEid) == bytes32(0); + } + + /// @dev Helper to view the state of a failed message + function failedGuidState(bytes32 _guid) public view returns (FailedState) { + FailedMessage memory failedMessage = failedMessages[_guid]; + + if (failedMessage.refundOFT == address(0) && failedMessage.oft == address(0)) { + return FailedState.NotFound; + } + if (failedMessage.refundOFT != address(0) && failedMessage.oft == address(0)) { + return FailedState.CanOnlyRefund; + } + if (failedMessage.refundOFT == address(0) && failedMessage.oft != address(0)) { + return FailedState.CanOnlyRetry; + } + + return FailedState.CanRetryWithSwap; + } + receive() external payable {} +} diff --git a/packages/ovault-composer-evm/contracts/OVaultUpgradeable.sol b/packages/ovault-composer-evm/contracts/OVaultUpgradeable.sol new file mode 100644 index 0000000000..842df83eac --- /dev/null +++ b/packages/ovault-composer-evm/contracts/OVaultUpgradeable.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract OVaultUpgradeable is ERC4626Upgradeable { + using SafeERC20 for IERC20; + using Math for uint256; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @dev Using solmate's implementation to work around rounding issues on initial minting + function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) { + uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. + + return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), rounding); + } + + /// @dev Using solmate's implementation to work around rounding issues on initial minting + function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view override returns (uint256) { + uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. + + return supply == 0 ? shares : shares.mulDiv(totalAssets(), supply, rounding); + } +} diff --git a/packages/ovault-composer-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-composer-evm/contracts/interfaces/IOVaultComposer.sol new file mode 100644 index 0000000000..d99a088efe --- /dev/null +++ b/packages/ovault-composer-evm/contracts/interfaces/IOVaultComposer.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import { IOAppComposer } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppComposer.sol"; +import { IOFT, SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; + +struct FailedMessage { + address oft; + SendParam sendParam; + address refundOFT; + SendParam refundSendParam; +} + +enum FailedState { + NotFound, + CanOnlyRefund, + CanOnlyRetry, + CanRetryWithSwap +} + +interface IOVaultComposer is IOAppComposer { + /// ========================== EVENTS ===================================== + event DecodeFailed(bytes32 indexed guid, address indexed oft, bytes message); + event Sent(bytes32 indexed guid, address indexed oft); + event SentOnHub(address indexed receiver, address indexed oft, uint256 amountLD); + event SendFailed(bytes32 indexed guid, address indexed oft); + event Refunded(bytes32 indexed guid, address indexed oft); + event Retried(bytes32 indexed guid, address indexed oft); + event GenericError(bytes32 indexed guid, address indexed oft, bytes errMsg); + event NoPeer(bytes32 indexed guid, address indexed oft, uint32 dstEid); + + /// ========================== Error Messages ===================================== + error ShareOFTShouldBeLockboxAdapter(address share); + + error OnlyEndpoint(address caller); + error OnlySelf(address caller); + error OnlyOFT(address oft); + error OnlyAsset(address asset); + error OnlyShare(address share); + error CanNotRefund(bytes32 guid); + error CanNotRetry(bytes32 guid); + error CanNotWithdraw(bytes32 guid); + error NotEnoughTargetTokens(uint256 amountLD, uint256 minAmountLD); + + /// ========================== GLOBAL VARIABLE FUNCTIONS ===================================== + function ASSET_OFT() external view returns (address); + function SHARE_OFT() external view returns (address); + function ENDPOINT() external view returns (address); + + /// ========================== FUNCTIONS ===================================== + function executeOVaultAction( + address _oft, + uint256 _amount, + SendParam calldata _sendParam + ) external returns (uint256 vaultAmount); + + function refund(bytes32 guid, bytes memory extraOptions) external payable; + function retry(bytes32 guid, bytes memory extraOptions) external payable; + function retryWithSwap(bytes32 guid, bytes memory extraOptions) external payable; + function send(address _oft, SendParam calldata _sendParam) external payable; + + function failedGuidState(bytes32 guid) external view returns (FailedState); + + receive() external payable; +} diff --git a/packages/ovault-composer-evm/foundry.toml b/packages/ovault-composer-evm/foundry.toml new file mode 100644 index 0000000000..74c97fb60f --- /dev/null +++ b/packages/ovault-composer-evm/foundry.toml @@ -0,0 +1,35 @@ +[profile.default] +solc = '0.8.22' +verbosity = 3 +src = "contracts" +test = "test" +out = "artifacts" +cache_path = "cache" +optimizer = true +optimizer_runs = 20_000 + +libs = [ + # We provide a set of useful contract utilities + # in the lib directory of @layerzerolabs/toolbox-foundry: + # + # - forge-std + # - ds-test + # - solidity-bytes-utils + 'node_modules/@layerzerolabs/toolbox-foundry/lib', + 'node_modules', +] + +remappings = [ + # Due to a misconfiguration of solidity-bytes-utils, an outdated version + # of forge-std is being dragged in + # + # To remedy this, we'll remap the ds-test and forge-std imports to our own versions + 'ds-test/=node_modules/@layerzerolabs/toolbox-foundry/lib/ds-test/', + 'forge-std/=node_modules/@layerzerolabs/toolbox-foundry/lib/forge-std/', + 'solidity-bytes-utils/contracts/=node_modules/@layerzerolabs/toolbox-foundry/lib/solidity-bytes-utils/', + '@layerzerolabs/=node_modules/@layerzerolabs/', + '@openzeppelin/=node_modules/@openzeppelin/', +] + +[fuzz] +runs = 1000 diff --git a/packages/ovault-composer-evm/test/OVault.t.sol b/packages/ovault-composer-evm/test/OVault.t.sol new file mode 100644 index 0000000000..fb188e4f9d --- /dev/null +++ b/packages/ovault-composer-evm/test/OVault.t.sol @@ -0,0 +1,464 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import { IERC20Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; + +import { MockOFT, MockOFTAdapter } from "./mocks/MockOFT.sol"; +import { MockOVault } from "./mocks/MockOVault.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { TestHelperOz5 } from "@layerzerolabs/test-devtools-evm-foundry/contracts/TestHelperOz5.sol"; + +import { console } from "forge-std/console.sol"; + +/// @dev Equivalent to Solmate's ERC4626 tests - https://github.com/transmissions11/solmate/blob/main/src/test/ERC4626.t.sol +contract OVaultTest is TestHelperOz5 { + using Math for uint256; + + MockOFT assetOFT; + MockOFTAdapter shareOFT; + MockOVault vault; + + string public constant ASSET_NAME = "Mock Token"; + string public constant ASSET_SYMBOL = "TKN"; + string public constant SHARE_NAME = "Mock Share"; + string public constant SHARE_SYMBOL = "SHARE"; + + uint32 internal constant A_EID = 1; + + function setUp() public override { + super.setUp(); + setUpEndpoints(1, LibraryType.UltraLightNode); + + assetOFT = new MockOFT(ASSET_NAME, ASSET_SYMBOL, address(endpoints[A_EID]), address(this)); + + vault = new MockOVault(SHARE_NAME, SHARE_SYMBOL, assetOFT.token()); + shareOFT = new MockOFTAdapter(address(vault), address(endpoints[A_EID]), address(this)); + } + + function test_ovault_invariantMetadata() public view { + assertEq(vault.name(), SHARE_NAME); + assertEq(vault.symbol(), SHARE_SYMBOL); + assertEq(vault.decimals(), 18); + } + + function testFuzz_ovault_SingleDepositWithdraw(uint128 amount) public { + if (amount == 0) amount = 1; + + uint256 aliceassetAmount = amount; + + address alice = address(0xABCD); + + assetOFT.mint(alice, aliceassetAmount); + + vm.prank(alice); + assetOFT.approve(address(vault), aliceassetAmount); + assertEq(assetOFT.allowance(alice, address(vault)), aliceassetAmount); + + uint256 alicePreDepositBal = assetOFT.balanceOf(alice); + + uint256 vaultTotalSupply = vault.totalSupply(); + uint256 assetTotalSupply = IERC20(vault.asset()).totalSupply(); + console.log("vault totalSupply", vaultTotalSupply); + console.log("asset totalSupply", assetTotalSupply); + + console.log("previewDeposit", vault.previewDeposit(aliceassetAmount)); + + vm.prank(alice); + uint256 aliceShareAmount = vault.deposit(aliceassetAmount, alice); + + // Expect exchange rate to be 1:1 on initial deposit. + assertEq(aliceassetAmount, aliceShareAmount); + assertEq(vault.previewWithdraw(aliceShareAmount), aliceassetAmount); + assertEq(vault.previewDeposit(aliceassetAmount), aliceShareAmount); + assertEq(vault.totalSupply(), aliceShareAmount); + assertEq(vault.totalAssets(), aliceassetAmount); + assertEq(vault.balanceOf(alice), aliceShareAmount); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), aliceassetAmount); + assertEq(assetOFT.balanceOf(alice), alicePreDepositBal - aliceassetAmount); + + vm.prank(alice); + vault.withdraw(aliceassetAmount, alice, alice); + + assertEq(vault.totalAssets(), 0); + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 0); + assertEq(assetOFT.balanceOf(alice), alicePreDepositBal); + } + + function testFuzz_ovault_SingleMintRedeem(uint128 amount) public { + if (amount == 0) amount = 1; + + uint256 aliceShareAmount = amount; + + address alice = address(0xABCD); + + assetOFT.mint(alice, aliceShareAmount); + + vm.prank(alice); + assetOFT.approve(address(vault), aliceShareAmount); + assertEq(assetOFT.allowance(alice, address(vault)), aliceShareAmount); + + uint256 alicePreDepositBal = assetOFT.balanceOf(alice); + + vm.prank(alice); + uint256 aliceUnderlyingAmount = vault.mint(aliceShareAmount, alice); + + // Expect exchange rate to be 1:1 on initial mint. + assertEq(aliceShareAmount, aliceUnderlyingAmount); + assertEq(vault.previewWithdraw(aliceShareAmount), aliceUnderlyingAmount); + assertEq(vault.previewDeposit(aliceUnderlyingAmount), aliceShareAmount); + assertEq(vault.totalSupply(), aliceShareAmount); + assertEq(vault.totalAssets(), aliceUnderlyingAmount); + assertEq(vault.balanceOf(alice), aliceUnderlyingAmount); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), aliceUnderlyingAmount); + assertEq(assetOFT.balanceOf(alice), alicePreDepositBal - aliceUnderlyingAmount); + + vm.prank(alice); + vault.redeem(aliceShareAmount, alice, alice); + + assertEq(vault.totalAssets(), 0); + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 0); + assertEq(assetOFT.balanceOf(alice), alicePreDepositBal); + } + + function test_ovault_MultipleMintDepositRedeemWithdraw() public { + // Scenario: + // A = Alice, B = Bob + // ________________________________________________________ + // | Vault shares | A share | A assets | B share | B assets | + // |========================================================| + // | 1. Alice mints 2000 shares (costs 2000 tokens) | + // |--------------|---------|----------|---------|----------| + // | 2000 | 2000 | 2000 | 0 | 0 | + // |--------------|---------|----------|---------|----------| + // | 2. Bob deposits 4000 tokens (mints 4000 shares) | + // |--------------|---------|----------|---------|----------| + // | 6000 | 2000 | 2000 | 4000 | 4000 | + // |--------------|---------|----------|---------|----------| + // | 3. Vault mutates by +3000 tokens... | + // | (simulated yield returned from strategy)... | + // |--------------|---------|----------|---------|----------| + // | 6000 | 2000 | 3000 | 4000 | 6000 | + // |--------------|---------|----------|---------|----------| + // | 4. Alice deposits 2000 tokens (mints 1333 shares) | + // |--------------|---------|----------|---------|----------| + // | 7333 | 3333 | 4999 | 4000 | 6000 | + // |--------------|---------|----------|---------|----------| + // | 5. Bob mints 2000 shares (costs 3001 assets) | + // | NOTE: Bob's assets spent got rounded up | + // | NOTE: Alice's vault assets got rounded up | + // |--------------|---------|----------|---------|----------| + // | 9333 | 3333 | 5000 | 6000 | 9000 | + // |--------------|---------|----------|---------|----------| + // | 6. Vault mutates by +3000 tokens... | + // | (simulated yield returned from strategy) | + // | NOTE: Vault holds 17001 tokens, but sum of | + // | assetsOf() is 17000. | + // |--------------|---------|----------|---------|----------| + // | 9333 | 3333 | 6071 | 6000 | 10929 | + // |--------------|---------|----------|---------|----------| + // | 7. Alice redeem 1333 shares (2428 assets) | + // |--------------|---------|----------|---------|----------| + // | 8000 | 2000 | 3643 | 6000 | 10929 | + // |--------------|---------|----------|---------|----------| + // | 8. Bob withdraws 2928 assets (1608 shares) | + // |--------------|---------|----------|---------|----------| + // | 6392 | 2000 | 3643 | 4392 | 8000 | + // |--------------|---------|----------|---------|----------| + // | 9. Alice withdraws 3643 assets (2000 shares) | + // | NOTE: Bob's assets have been rounded back up | + // |--------------|---------|----------|---------|----------| + // | 4392 | 0 | 0 | 4392 | 8001 | + // |--------------|---------|----------|---------|----------| + // | 10. Bob redeem 4392 shares (8001 tokens) | + // |--------------|---------|----------|---------|----------| + // | 0 | 0 | 0 | 0 | 0 | + // |______________|_________|__________|_________|__________| + + address alice = address(0xABCD); + address bob = address(0xDCBA); + + uint256 mutationassetAmount = 3000; + + assetOFT.mint(alice, 4000); + + vm.prank(alice); + assetOFT.approve(address(vault), 4000); + + assertEq(assetOFT.allowance(alice, address(vault)), 4000); + + assetOFT.mint(bob, 7001); + + vm.prank(bob); + assetOFT.approve(address(vault), 7001); + + assertEq(assetOFT.allowance(bob, address(vault)), 7001); + + // 1. Alice mints 2000 shares (costs 2000 tokens) + vm.prank(alice); + uint256 aliceassetAmount = vault.mint(2000, alice); + + uint256 aliceShareAmount = vault.previewDeposit(aliceassetAmount); + + // Expect to have received the requested mint amount. + assertEq(aliceShareAmount, 2000); + assertEq(vault.balanceOf(alice), aliceShareAmount); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), aliceassetAmount); + assertEq(vault.convertToShares(aliceassetAmount), vault.balanceOf(alice)); + + // Expect a 1:1 ratio before mutation. + assertEq(aliceassetAmount, 2000); + + // Sanity check. + assertEq(vault.totalSupply(), aliceShareAmount); + assertEq(vault.totalAssets(), aliceassetAmount); + + // 2. Bob deposits 4000 tokens (mints 4000 shares) + vm.prank(bob); + uint256 bobShareAmount = vault.deposit(4000, bob); + uint256 bobassetAmount = vault.previewWithdraw(bobShareAmount); + + // Expect to have received the requested asset amount. + assertEq(bobassetAmount, 4000); + assertEq(vault.balanceOf(bob), bobShareAmount); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), bobassetAmount); + assertEq(vault.convertToShares(bobassetAmount), vault.balanceOf(bob)); + + // Expect a 1:1 ratio before mutation. + assertEq(bobShareAmount, bobassetAmount); + + // Sanity check. + uint256 preMutationShareBal = aliceShareAmount + bobShareAmount; + uint256 preMutationBal = aliceassetAmount + bobassetAmount; + assertEq(vault.totalSupply(), preMutationShareBal); + assertEq(vault.totalAssets(), preMutationBal); + assertEq(vault.totalSupply(), 6000); + assertEq(vault.totalAssets(), 6000); + + // 3. Vault mutates by +3000 tokens... | + // (simulated yield returned from strategy)... + // The Vault now contains more tokens than deposited which causes the exchange rate to change. + // Alice share is 33.33% of the Vault, Bob 66.66% of the Vault. + // Alice's share count stays the same but the asset amount changes from 2000 to 3000. + // Bob's share count stays the same but the asset amount changes from 4000 to 6000. + assetOFT.mint(address(vault), mutationassetAmount); + assertEq(vault.totalSupply(), preMutationShareBal); + assertEq(vault.totalAssets(), preMutationBal + mutationassetAmount); + assertEq(vault.balanceOf(alice), aliceShareAmount); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), aliceassetAmount + (mutationassetAmount / 3) * 1); + assertEq(vault.balanceOf(bob), bobShareAmount); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), bobassetAmount + (mutationassetAmount / 3) * 2); + + // 4. Alice deposits 2000 tokens (mints 1333 shares) + vm.prank(alice); + vault.deposit(2000, alice); + + assertEq(vault.totalSupply(), 7333); + assertEq(vault.balanceOf(alice), 3333); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 4999); + assertEq(vault.balanceOf(bob), 4000); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), 6000); + + // 5. Bob mints 2000 shares (costs 3001 assets) + // NOTE: Bob's assets spent got rounded up + // NOTE: Alices's vault assets got rounded up + vm.prank(bob); + vault.mint(2000, bob); + + assertEq(vault.totalSupply(), 9333); + assertEq(vault.balanceOf(alice), 3333); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 5000); + assertEq(vault.balanceOf(bob), 6000); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), 9000); + + // Sanity checks: + // Alice and bob should have spent all their tokens now + assertEq(assetOFT.balanceOf(alice), 0); + assertEq(assetOFT.balanceOf(bob), 0); + // Assets in vault: 4k (alice) + 7k (bob) + 3k (yield) + 1 (round up) + assertEq(vault.totalAssets(), 14001); + + // 6. Vault mutates by +3000 tokens + // NOTE: Vault holds 17001 tokens, but sum of assetsOf() is 17000. + assetOFT.mint(address(vault), mutationassetAmount); + assertEq(vault.totalAssets(), 17001); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 6071); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), 10929); + + // 7. Alice redeem 1333 shares (2428 assets) + vm.prank(alice); + vault.redeem(1333, alice, alice); + + assertEq(assetOFT.balanceOf(alice), 2428); + assertEq(vault.totalSupply(), 8000); + assertEq(vault.totalAssets(), 14573); + assertEq(vault.balanceOf(alice), 2000); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 3643); + assertEq(vault.balanceOf(bob), 6000); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), 10929); + + // 8. Bob withdraws 2929 assets (1608 shares) + vm.prank(bob); + vault.withdraw(2929, bob, bob); + + assertEq(assetOFT.balanceOf(bob), 2929); + assertEq(vault.totalSupply(), 6392); + assertEq(vault.totalAssets(), 11644); + assertEq(vault.balanceOf(alice), 2000); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 3643); + assertEq(vault.balanceOf(bob), 4392); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), 8000); + + // 9. Alice withdraws 3643 assets (2000 shares) + // NOTE: Bob's assets have been rounded back up + vm.prank(alice); + vault.withdraw(3643, alice, alice); + + assertEq(assetOFT.balanceOf(alice), 6071); + assertEq(vault.totalSupply(), 4392); + assertEq(vault.totalAssets(), 8001); + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 0); + assertEq(vault.balanceOf(bob), 4392); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), 8001); + + // 10. Bob redeem 4392 shares (8001 tokens) + vm.prank(bob); + vault.redeem(4392, bob, bob); + assertEq(assetOFT.balanceOf(bob), 10930); + assertEq(vault.totalSupply(), 0); + assertEq(vault.totalAssets(), 0); + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.convertToAssets(vault.balanceOf(alice)), 0); + assertEq(vault.balanceOf(bob), 0); + assertEq(vault.convertToAssets(vault.balanceOf(bob)), 0); + + // Sanity check + assertEq(assetOFT.balanceOf(address(vault)), 0); + } + + function test_ovault_FailDepositWithNotEnoughApproval() public { + assetOFT.mint(address(this), 0.5e18); + assetOFT.approve(address(vault), 0.5e18); + assertEq(assetOFT.allowance(address(this), address(vault)), 0.5e18); + + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(vault), 0.5e18, 1e18) + ); + vault.deposit(1e18, address(this)); + } + + function test_ovault_FailWithdrawExceedsMaxWithdraw() public { + assetOFT.mint(address(this), 0.5e18); + assetOFT.approve(address(vault), 0.5e18); + + vault.deposit(0.5e18, address(this)); + + vm.expectRevert( + abi.encodeWithSelector(ERC4626.ERC4626ExceededMaxWithdraw.selector, address(this), 1e18, 0.5e18) + ); + vault.withdraw(1e18, address(this), address(this)); + } + + function test_ovault_FailRedeemWithNotEnoughShareAmount() public { + assetOFT.mint(address(this), 0.5e18); + assetOFT.approve(address(vault), 0.5e18); + + vault.deposit(0.5e18, address(this)); + + vm.expectRevert(abi.encodeWithSelector(ERC4626.ERC4626ExceededMaxRedeem.selector, address(this), 1e18, 0.5e18)); + vault.redeem(1e18, address(this), address(this)); + } + + function test_ovault_FailWithdrawWithNoassetAmount() public { + vm.expectRevert(abi.encodeWithSelector(ERC4626.ERC4626ExceededMaxWithdraw.selector, address(this), 1e18, 0)); + vault.withdraw(1e18, address(this), address(this)); + } + + function test_ovault_FailRedeemWithNoShareAmount() public { + vm.expectRevert(abi.encodeWithSelector(ERC4626.ERC4626ExceededMaxRedeem.selector, address(this), 1e18, 0)); + vault.redeem(1e18, address(this), address(this)); + } + + function test_ovault_FailDepositWithNoApproval() public { + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(vault), 0, 1e18) + ); + vault.deposit(1e18, address(this)); + } + + function test_ovault_FailMintWithNoApproval() public { + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(vault), 0, 1e18) + ); + vault.mint(1e18, address(this)); + } + + function test_ovault_MintZero() public { + vault.mint(0, address(this)); + + assertEq(vault.balanceOf(address(this)), 0); + assertEq(vault.convertToAssets(vault.balanceOf(address(this))), 0); + assertEq(vault.totalSupply(), 0); + assertEq(vault.totalAssets(), 0); + } + + function test_ovault_WithdrawZero() public { + vault.withdraw(0, address(this), address(this)); + + assertEq(vault.balanceOf(address(this)), 0); + assertEq(vault.convertToAssets(vault.balanceOf(address(this))), 0); + assertEq(vault.totalSupply(), 0); + assertEq(vault.totalAssets(), 0); + } + + function test_ovault_VaultInteractionsForSomeoneElse() public { + // init 2 users with a 1e18 balance + address alice = address(0xABCD); + address bob = address(0xDCBA); + assetOFT.mint(alice, 1e18); + assetOFT.mint(bob, 1e18); + + vm.prank(alice); + assetOFT.approve(address(vault), 1e18); + + vm.prank(bob); + assetOFT.approve(address(vault), 1e18); + + // alice deposits 1e18 for bob + vm.prank(alice); + vault.deposit(1e18, bob); + + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.balanceOf(bob), 1e18); + assertEq(assetOFT.balanceOf(alice), 0); + + // bob mint 1e18 for alice + vm.prank(bob); + vault.mint(1e18, alice); + assertEq(vault.balanceOf(alice), 1e18); + assertEq(vault.balanceOf(bob), 1e18); + assertEq(assetOFT.balanceOf(bob), 0); + + // alice redeem 1e18 for bob + vm.prank(alice); + vault.redeem(1e18, bob, alice); + + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.balanceOf(bob), 1e18); + assertEq(assetOFT.balanceOf(bob), 1e18); + + // bob withdraw 1e18 for alice + vm.prank(bob); + vault.withdraw(1e18, alice, bob); + + assertEq(vault.balanceOf(alice), 0); + assertEq(vault.balanceOf(bob), 0); + assertEq(assetOFT.balanceOf(alice), 1e18); + } +} diff --git a/packages/ovault-composer-evm/test/composer/OVaultComposer_Base.t.sol b/packages/ovault-composer-evm/test/composer/OVaultComposer_Base.t.sol new file mode 100644 index 0000000000..abad402250 --- /dev/null +++ b/packages/ovault-composer-evm/test/composer/OVaultComposer_Base.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +// OApp imports +import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol"; + +// OFT imports +import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; +import { SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; + +import { OVaultComposer } from "../../contracts/OVaultComposer.sol"; + +import { MockOFT } from "../mocks/MockOFT.sol"; +import { MockOFTAdapter } from "../mocks/MockOFT.sol"; +import { MockOVault } from "../mocks/MockOVault.sol"; + +// Forge imports +import "forge-std/console.sol"; + +// DevTools imports +import { TestHelperOz5 } from "@layerzerolabs/test-devtools-evm-foundry/contracts/TestHelperOz5.sol"; + +contract OVaultComposerBaseTest is TestHelperOz5 { + using OptionsBuilder for bytes; + + uint8 subMeshSize = 3; + + uint32 public constant ETH_EID = 1; + uint32 public constant ARB_EID = 2; + uint32 public constant POL_EID = 3; + uint32 public constant BAD_EID = 101; + + MockOFT public assetOFT_arb; + MockOFTAdapter public shareOFT_arb; + + MockOFT public assetOFT_eth; + MockOFT public shareOFT_eth; + + MockOFT public assetOFT_pol; + MockOFT public shareOFT_pol; + + MockOVault public oVault_arb; + OVaultComposer public OVaultComposerArb; + + address public userA = makeAddr("userA"); + address public userB = makeAddr("userB"); + + address public arbEndpoint; + address public arbExecutor = makeAddr("arbExecutor"); + bytes public OPTIONS_LZRECEIVE_2M = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0); + + uint256 public constant INITIAL_BALANCE = 100 ether; + uint256 public constant TOKENS_TO_SEND = 1 ether; + + function setUp() public virtual override { + super.setUp(); + setUpEndpoints(subMeshSize, LibraryType.UltraLightNode); + + arbEndpoint = address(endpoints[ARB_EID]); + + /// @dev Deploy the Asset OFT (we can expect them to exist before we deploy the OVaultComposer) + assetOFT_arb = new MockOFT("arbAsset", "arbAsset", address(endpoints[ARB_EID]), address(this)); + assetOFT_eth = new MockOFT("ethAsset", "ethAsset", address(endpoints[ETH_EID]), address(this)); + assetOFT_pol = new MockOFT("polAsset", "polAsset", address(endpoints[POL_EID]), address(this)); + + // config and wire the ofts + address[] memory nativeMeshOFTs = new address[](subMeshSize); + nativeMeshOFTs[0] = address(assetOFT_eth); + nativeMeshOFTs[1] = address(assetOFT_arb); + nativeMeshOFTs[2] = address(assetOFT_pol); + this.wireOApps(nativeMeshOFTs); + + /// Now the "expansion" is for the arb vault and share ofts on other networks. + oVault_arb = new MockOVault("arbShare", "arbShare", address(assetOFT_arb)); + shareOFT_arb = new MockOFTAdapter(address(oVault_arb), address(endpoints[ARB_EID]), address(this)); + OVaultComposerArb = new OVaultComposer(address(oVault_arb), address(assetOFT_arb), address(shareOFT_arb)); + + /// Deploy the Share OFTs on other networks - these are NOT lockbox adapters. + shareOFT_eth = new MockOFT("ethShare", "ethShare", address(endpoints[ETH_EID]), address(this)); + shareOFT_pol = new MockOFT("polShare", "polShare", address(endpoints[POL_EID]), address(this)); + + address[] memory usdt0OFTs = new address[](subMeshSize); + usdt0OFTs[0] = address(shareOFT_eth); + usdt0OFTs[1] = address(shareOFT_arb); + usdt0OFTs[2] = address(shareOFT_pol); + this.wireOApps(usdt0OFTs); + + vm.label(address(assetOFT_arb), "AssetOFT::arb"); + vm.label(address(assetOFT_eth), "AssetOFT::eth"); + vm.label(address(assetOFT_pol), "AssetOFT::pol"); + + vm.label(address(shareOFT_arb), "ShareOFTAdapter::arb"); + vm.label(address(shareOFT_eth), "ShareOFT::eth"); + vm.label(address(shareOFT_pol), "ShareOFT::pol"); + + vm.label(address(oVault_arb), "OVault::arb"); + vm.label(address(OVaultComposerArb), "OVaultComposer::arb"); + + deal(arbExecutor, INITIAL_BALANCE); + deal(arbEndpoint, INITIAL_BALANCE); + } + + function _createComposePayload( + uint32 _srcEid, + SendParam memory _sendParam, + uint256 _amount, + address _msgSender + ) internal pure returns (bytes memory composeMsg) { + composeMsg = OFTComposeMsgCodec.encode( + 0, + _srcEid, + _amount, + abi.encodePacked(addressToBytes32(_msgSender), abi.encode(_sendParam)) + ); + } + + function _createComposePayload( + uint32 _srcEid, + bytes memory _composeMsg, + uint256 _amount, + address _msgSender + ) internal pure returns (bytes memory composeMsg) { + composeMsg = OFTComposeMsgCodec.encode( + 0, + _srcEid, + _amount, + abi.encodePacked(addressToBytes32(_msgSender), _composeMsg) + ); + } + + function _setTradeRatioAssetToShare( + uint256 _assetNum, + uint256 _shareNum + ) internal returns (uint256 mintAssets, uint256 mintShares) { + mintAssets = _assetNum * TOKENS_TO_SEND; + mintShares = _shareNum * TOKENS_TO_SEND; + + oVault_arb.mint(address(0xbeef), mintShares); + assetOFT_arb.mint(address(oVault_arb), mintAssets); + } + + function _randomGUID() internal view returns (bytes32) { + return bytes32(vm.randomBytes(32)); + } + + function assertEq(uint256 term1, uint256 term2, uint256 term3) internal pure { + assertEq(term1, term2, "term1 != term2"); + assertEq(term1, term3, "term1 != term3"); + } + + function assertEmpty(SendParam memory _sendParam) internal pure { + assertEq(_sendParam.dstEid, 0, "dstEid should be empty"); + assertEq(_sendParam.to, bytes32(0), "to should be empty"); + assertEq(_sendParam.amountLD, 0, "amountLD should be empty"); + assertEq(_sendParam.minAmountLD, 0, "minAmountLD should be empty"); + assertEq(_sendParam.extraOptions, bytes(""), "extraOptions should be empty"); + } + + function assertEq(SendParam memory _term1, SendParam memory _term2) internal pure { + assertEq(_term1.dstEid, _term2.dstEid, "dstEid should be equal"); + assertEq(_term1.to, _term2.to, "to should be equal"); + assertEq(_term1.amountLD, _term2.amountLD, "amountLD should be equal"); + assertEq(_term1.minAmountLD, _term2.minAmountLD, "minAmountLD should be equal"); + assertEq(_term1.extraOptions, _term2.extraOptions, "extraOptions should be equal"); + } +} diff --git a/packages/ovault-composer-evm/test/composer/OVaultComposer_E2E.t.sol b/packages/ovault-composer-evm/test/composer/OVaultComposer_E2E.t.sol new file mode 100644 index 0000000000..351fc8c98d --- /dev/null +++ b/packages/ovault-composer-evm/test/composer/OVaultComposer_E2E.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +// OApp imports +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +import { ILayerZeroEndpointV2 } from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroEndpointV2.sol"; +import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol"; +import { IOFT } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; + +import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol"; +import { SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; +import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; + +import { IOVaultComposer, FailedState } from "../../contracts/interfaces/IOVaultComposer.sol"; +import { OVaultComposer } from "../../contracts/OVaultComposer.sol"; + +import { OVaultComposerBaseTest } from "./OVaultComposer_Base.t.sol"; +import { console } from "forge-std/console.sol"; + +contract OVaultComposerE2ETest is OVaultComposerBaseTest { + using OptionsBuilder for bytes; + + /// @dev Not profiled + uint128 constant lzReceiveGasValue = 800_000; + uint128 constant lzComposeGasValue = 800_000; + + /// @dev Seems to consume about 2.2 gwei + uint128 constant lzComposeMsgValue = 0.000025 ether; + + function setUp() public virtual override { + super.setUp(); + + vm.deal(userA, 1000 ether); + } + + function test_E2E_ethereum_to_polygon() public { + uint256 shareTokensToReceive = TOKENS_TO_SEND * 2; + + deal(address(assetOFT_eth), userA, TOKENS_TO_SEND); + + (uint256 mintAssets, ) = _setTradeRatioAssetToShare(1, 2); + + address composerAddress = address(OVaultComposerArb); + uint256 initialPolygonBalance = shareOFT_pol.balanceOf(userA); + + /// @dev This is the send param that is passed as the compose payload to the final OFT + SendParam memory arbToPolSendParam = SendParam( + POL_EID, + addressToBytes32(userA), + 0, + shareTokensToReceive, + OptionsBuilder.newOptions().addExecutorLzReceiveOption(lzReceiveGasValue, 0), + "", + "" + ); + bytes memory composePayload = abi.encode(arbToPolSendParam); + + /// @dev Building the NativeMesh ETH -> NativeMesh Arb send param + bytes memory options = OptionsBuilder + .newOptions() + .addExecutorLzReceiveOption(lzReceiveGasValue, 0) + .addExecutorLzComposeOption(0, lzComposeGasValue, lzComposeMsgValue); + + SendParam memory ethToArbSendParam = SendParam( + ARB_EID, + addressToBytes32(composerAddress), + TOKENS_TO_SEND, + (TOKENS_TO_SEND * 9995) / 10000, + options, + composePayload, + "" + ); + + MessagingFee memory fee = assetOFT_eth.quoteSend(ethToArbSendParam, false); + + vm.startPrank(userA); + assetOFT_eth.send{ value: fee.nativeFee }(ethToArbSendParam, fee, payable(address(this))); + vm.stopPrank(); + + assertEq(assetOFT_arb.balanceOf(address(oVault_arb)), assetOFT_arb.totalSupply(), mintAssets); + + verifyPackets(ARB_EID, addressToBytes32(address(assetOFT_arb))); + + assertEq( + assetOFT_arb.balanceOf(composerAddress) + assetOFT_arb.balanceOf(address(oVault_arb)), + assetOFT_arb.totalSupply(), + mintAssets + TOKENS_TO_SEND + ); + + bytes memory composeMsg = OFTComposeMsgCodec.encode( + 0, + ETH_EID, + TOKENS_TO_SEND, + abi.encodePacked(addressToBytes32(userA), composePayload) + ); + + vm.prank(arbEndpoint); + vm.deal(address(arbEndpoint), 1000 ether); + OVaultComposerArb.lzCompose{ value: lzComposeMsgValue, gas: lzComposeGasValue }( + address(assetOFT_arb), + addressToBytes32(address(assetOFT_arb)), + composeMsg, + address(this), + "" + ); + assertEq( + assetOFT_arb.balanceOf(composerAddress), + 0, + "composerAddress should have no tokens after lzCompose on arb" + ); + + verifyPackets(POL_EID, addressToBytes32(address(shareOFT_pol))); + uint256 finalPolygonBalance = shareOFT_pol.balanceOf(userA); + + assertEq( + finalPolygonBalance - initialPolygonBalance, + shareTokensToReceive, + "userA should have all tokens after lzReceive on polygon share oft" + ); + } +} diff --git a/packages/ovault-composer-evm/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-composer-evm/test/composer/OVaultComposer_Unit.t.sol new file mode 100644 index 0000000000..fa492be856 --- /dev/null +++ b/packages/ovault-composer-evm/test/composer/OVaultComposer_Unit.t.sol @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +import { IOFT } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; +import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol"; +import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol"; +import { SendParam } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; + +import { IOVaultComposer, FailedState } from "../../contracts/interfaces/IOVaultComposer.sol"; +import { OVaultComposer } from "../../contracts/OVaultComposer.sol"; +import { OVaultComposerBaseTest } from "./OVaultComposer_Base.t.sol"; + +import { console } from "forge-std/console.sol"; + +contract OVaultComposerUnitTest is OVaultComposerBaseTest { + using OptionsBuilder for bytes; + + function setUp() public virtual override { + super.setUp(); + } + + function test_deployment() public view { + assertEq(address(OVaultComposerArb.OVAULT()), address(oVault_arb)); + assertEq(OVaultComposerArb.SHARE_OFT(), address(shareOFT_arb)); + assertEq(OVaultComposerArb.ASSET_OFT(), address(assetOFT_arb)); + } + + function test_onlyEndpoint() public { + vm.expectRevert(abi.encodeWithSelector(IOVaultComposer.OnlyEndpoint.selector, address(this))); + OVaultComposerArb.lzCompose(address(assetOFT_arb), _randomGUID(), "", userA, ""); + } + + function test_onlyOFT(address _oft) public { + vm.assume(_oft != address(assetOFT_arb) && _oft != address(shareOFT_arb)); + + vm.expectRevert(abi.encodeWithSelector(IOVaultComposer.OnlyOFT.selector, _oft)); + vm.prank(arbEndpoint); + OVaultComposerArb.lzCompose{ value: 1 ether }(_oft, _randomGUID(), "", arbExecutor, ""); + } + + function test_lzCompose_pass() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userA), + TOKENS_TO_SEND, + 0, + OPTIONS_LZRECEIVE_2M, + "", + "" + ); + + bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(assetOFT_arb)); + emit IERC20.Transfer(address(OVaultComposerArb), address(oVault_arb), TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(oVault_arb)); + emit IERC20.Transfer(address(0), address(OVaultComposerArb), TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(oVault_arb)); + emit IERC4626.Deposit(address(OVaultComposerArb), address(OVaultComposerArb), TOKENS_TO_SEND, TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + emit IOVaultComposer.Sent(guid, address(shareOFT_arb)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.prank(arbEndpoint); + OVaultComposerArb.lzCompose{ value: 1 ether }(address(assetOFT_arb), guid, composeMsg, arbExecutor, ""); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(shareOFT_arb)), TOKENS_TO_SEND); + } + + function test_lzCompose_pass_on_hub() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + SendParam memory internalSendParam = SendParam( + OVaultComposerArb.HUB_EID(), + addressToBytes32(userA), + TOKENS_TO_SEND, + 0, + OPTIONS_LZRECEIVE_2M, + "", + "" + ); + + bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(assetOFT_arb)); + emit IERC20.Transfer(address(OVaultComposerArb), address(oVault_arb), TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(oVault_arb)); + emit IERC20.Transfer(address(0), address(OVaultComposerArb), TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(oVault_arb)); + emit IERC4626.Deposit(address(OVaultComposerArb), address(OVaultComposerArb), TOKENS_TO_SEND, TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + emit IOVaultComposer.SentOnHub(userA, address(shareOFT_arb), TOKENS_TO_SEND); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.prank(arbEndpoint); + OVaultComposerArb.lzCompose{ value: 1 ether }(address(assetOFT_arb), guid, composeMsg, arbExecutor, ""); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(userA)), TOKENS_TO_SEND); + } + + function test_lzCompose_fail_invalid_payload() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + bytes memory invalidPayload = bytes("0x1234"); + + bytes memory composeMsg = _createComposePayload(ETH_EID, invalidPayload, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + emit IOVaultComposer.DecodeFailed(guid, address(assetOFT_arb), invalidPayload); + + vm.prank(arbEndpoint); + OVaultComposerArb.lzCompose{ value: 1 ether }(address(assetOFT_arb), guid, composeMsg, arbExecutor, ""); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanOnlyRefund)); + + ( + address oft, + SendParam memory sendParam, + address refundOFT, + SendParam memory refundSendParam + ) = OVaultComposerArb.failedMessages(guid); + + assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); + assertEq(oft, address(0), "retry oft should be 0 - not possible"); + assertEq(refundSendParam.dstEid, ETH_EID, "refund dstEid should be ETH_EID"); + assertEq(refundSendParam.to, addressToBytes32(userA), "refund to should be userA"); + assertEq(refundSendParam.amountLD, TOKENS_TO_SEND, "refund amountLD should be TOKENS_TO_SEND"); + assertEq(refundSendParam.minAmountLD, 0, "refund minAmountLD should be 0"); + assertEq(refundSendParam.extraOptions, "", "refund extraOptions should be empty"); + + assertEmpty(sendParam); + } + + function test_lzCompose_quoteSend_fail() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + SendParam memory internalSendParam = SendParam( + BAD_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + 0, + OPTIONS_LZRECEIVE_2M, + "", + "" + ); + + bytes memory composePayload = abi.encode(internalSendParam); + bytes memory composeMsg = _createComposePayload(ETH_EID, composePayload, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + emit IOVaultComposer.NoPeer(guid, address(shareOFT_arb), BAD_EID); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.prank(arbEndpoint); + OVaultComposerArb.lzCompose{ value: 1 ether }(address(assetOFT_arb), guid, composeMsg, arbExecutor, ""); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanOnlyRefund)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + ( + address oft, + SendParam memory sendParam, + address refundOFT, + SendParam memory refundSendParam + ) = OVaultComposerArb.failedMessages(guid); + + assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); + assertEq(oft, address(0), "retry oft should be 0 - not possible"); + + assertEq(refundSendParam.dstEid, ETH_EID, "refund dstEid should be ETH_EID"); + assertEq(refundSendParam.to, addressToBytes32(userA), "refund to should be userA"); + assertEq(refundSendParam.amountLD, TOKENS_TO_SEND, "refund amountLD should be TOKENS_TO_SEND"); + assertEq(refundSendParam.minAmountLD, 0, "refund minAmountLD should be 0"); + assertEq(refundSendParam.extraOptions, bytes(""), "refund extraOptions should be empty"); + + SendParam memory expectedSendParam = internalSendParam; + expectedSendParam.amountLD = 0; + + assertEq(sendParam, expectedSendParam); + } + + function test_lzCompose_slippage_on_target_token() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + TOKENS_TO_SEND + 1, + OPTIONS_LZRECEIVE_2M, + "", + "" + ); + + bytes memory composePayload = abi.encode(internalSendParam); + bytes memory composeMsg = _createComposePayload(ETH_EID, composePayload, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + bytes memory errMsg = abi.encodeWithSelector( + IOVaultComposer.NotEnoughTargetTokens.selector, + TOKENS_TO_SEND, + TOKENS_TO_SEND + 1 + ); + emit IOVaultComposer.GenericError(guid, address(shareOFT_arb), errMsg); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.prank(arbEndpoint); + OVaultComposerArb.lzCompose{ value: 1 ether }(address(assetOFT_arb), guid, composeMsg, arbExecutor, ""); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwap)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + ( + address oft, + SendParam memory sendParam, + address refundOFT, + SendParam memory refundSendParam + ) = OVaultComposerArb.failedMessages(guid); + + assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); + assertEq(oft, address(shareOFT_arb), "retry oft should be shareOFT_arb"); + + assertEq(refundSendParam.dstEid, ETH_EID, "refund dstEid should be ETH_EID"); + assertEq(refundSendParam.to, addressToBytes32(userA), "refund to should be userA"); + assertEq(refundSendParam.amountLD, TOKENS_TO_SEND, "refund amountLD should be TOKENS_TO_SEND"); + assertEq(refundSendParam.minAmountLD, 0, "refund minAmountLD should be TOKENS_TO_SEND + 1"); + assertEq(refundSendParam.extraOptions, bytes(""), "refund extraOptions should be empty"); + + SendParam memory expectedSendParam = internalSendParam; + expectedSendParam.amountLD = 0; + + assertEq(sendParam, expectedSendParam); + } + + function test_lzCompose_fail_insufficient_fee_amount() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + 0, + OPTIONS_LZRECEIVE_2M, + "", + "" + ); + + bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(assetOFT_arb)); + emit IERC20.Transfer(address(OVaultComposerArb), address(oVault_arb), TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(oVault_arb)); + emit IERC20.Transfer(address(0), address(OVaultComposerArb), TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(oVault_arb)); + emit IERC4626.Deposit(address(OVaultComposerArb), address(OVaultComposerArb), TOKENS_TO_SEND, TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + emit IOVaultComposer.SendFailed(guid, address(shareOFT_arb)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.prank(arbEndpoint); + OVaultComposerArb.lzCompose(address(assetOFT_arb), guid, composeMsg, arbExecutor, ""); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanOnlyRetry)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + + ( + address oft, + SendParam memory sendParam, + address refundOFT, + SendParam memory refundSendParam + ) = OVaultComposerArb.failedMessages(guid); + + assertEq(refundOFT, address(0), "refundOFT should be 0 - not possible"); + assertEq(oft, address(shareOFT_arb), "retry oft should be shareOFT_arb"); + assertEq(sendParam.dstEid, POL_EID, "retry dstEid should be POL_EID"); + assertEq(sendParam.to, addressToBytes32(userB), "retry to should be userB"); + assertEq(sendParam.amountLD, TOKENS_TO_SEND, "retry amountLD should be TOKENS_TO_SEND"); + assertEq(sendParam.minAmountLD, 0, "retry minAmountLD should be 0"); + assertEq(sendParam.extraOptions, OPTIONS_LZRECEIVE_2M, "retry extraOptions should be OPTIONS_LZRECEIVE_2M"); + + assertEq(refundSendParam.dstEid, ETH_EID, "refund dstEid should be ETH_EID"); + assertEq(refundSendParam.to, addressToBytes32(userA), "refund to should be userA"); + assertEq(refundSendParam.amountLD, TOKENS_TO_SEND, "refund amountLD should be TOKENS_TO_SEND"); + assertEq(refundSendParam.minAmountLD, 0, "refund minAmountLD should be 0"); + assertEq(refundSendParam.extraOptions, bytes(""), "refund extraOptions should be empty"); + } + + function test_lzCompose_slippage_retry_with_swap() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + uint256 targetAmount = TOKENS_TO_SEND * 2; + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + targetAmount, + OPTIONS_LZRECEIVE_2M, + "", + "" + ); + + bytes memory composePayload = abi.encode(internalSendParam); + bytes memory composeMsg = _createComposePayload(ETH_EID, composePayload, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + bytes memory errMsg = abi.encodeWithSelector( + IOVaultComposer.NotEnoughTargetTokens.selector, + TOKENS_TO_SEND, + targetAmount + ); + emit IOVaultComposer.GenericError(guid, address(shareOFT_arb), errMsg); + + vm.prank(arbEndpoint); + OVaultComposerArb.lzCompose{ value: 1 ether }(address(assetOFT_arb), guid, composeMsg, arbExecutor, ""); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwap)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); + + OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), mintAssets + TOKENS_TO_SEND); + + assertEq( + oVault_arb.totalSupply(), + oVault_arb.balanceOf(address(0xbeef)) + oVault_arb.balanceOf(address(shareOFT_arb)), + mintShares + targetAmount + ); + } +} diff --git a/packages/ovault-composer-evm/test/mocks/MockERC20.sol b/packages/ovault-composer-evm/test/mocks/MockERC20.sol new file mode 100644 index 0000000000..a2c029eabc --- /dev/null +++ b/packages/ovault-composer-evm/test/mocks/MockERC20.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) {} + + function mint(address to, uint256 value) public virtual { + _mint(to, value); + } + + function burn(address from, uint256 value) public virtual { + _burn(from, value); + } +} diff --git a/packages/ovault-composer-evm/test/mocks/MockOFT.sol b/packages/ovault-composer-evm/test/mocks/MockOFT.sol new file mode 100644 index 0000000000..a1bda95699 --- /dev/null +++ b/packages/ovault-composer-evm/test/mocks/MockOFT.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +import { OFT } from "@layerzerolabs/oft-evm/contracts/OFT.sol"; +import { OFTAdapter } from "@layerzerolabs/oft-evm/contracts/OFTAdapter.sol"; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +contract MockOFT is OFT { + constructor( + string memory _name, + string memory _symbol, + address _lzEndpoint, + address _delegate + ) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {} + + function mint(address to, uint256 value) public virtual { + _mint(to, value); + } + + function burn(address from, uint256 value) public virtual { + _burn(from, value); + } +} + +contract MockOFTAdapter is OFTAdapter { + constructor( + address _token, + address _lzEndpoint, + address _delegate + ) OFTAdapter(_token, _lzEndpoint, _delegate) Ownable(msg.sender) {} +} diff --git a/packages/ovault-composer-evm/test/mocks/MockOVault.sol b/packages/ovault-composer-evm/test/mocks/MockOVault.sol new file mode 100644 index 0000000000..30724b9b10 --- /dev/null +++ b/packages/ovault-composer-evm/test/mocks/MockOVault.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { OVault } from "../../contracts/OVault.sol"; +import { OVaultUpgradeable } from "../../contracts/OVaultUpgradeable.sol"; + +contract MockOVault is OVault { + constructor(string memory name, string memory symbol, address asset) OVault(name, symbol, asset) {} + + function totalAssets() public view override returns (uint256) { + return IERC20(asset()).balanceOf(address(this)); + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public { + _burn(from, amount); + } +} + +contract MockOVaultUpgradeable is OVaultUpgradeable { + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() OVaultUpgradeable() { + _disableInitializers(); + } + + function __OVault_init(string memory _name, string memory _symbol, address _asset) internal onlyInitializing { + __ERC4626_init(IERC20(_asset)); + __ERC20_init(_name, _symbol); + } + + function totalAssets() public view override returns (uint256) { + return IERC20(asset()).balanceOf(address(this)); + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public { + _burn(from, amount); + } +}