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 @@
+
+
+
+
+
+
+@layerzerolabs/ovault-composer
+
+
+
+
+
+
+
+
+
+
+
+## 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);
+ }
+}