diff --git a/packages/ovault-composer/contracts/ERC4626Adapter.sol b/packages/ovault-composer/contracts/ERC4626Adapter.sol index f695acad4f..8cfaa04507 100644 --- a/packages/ovault-composer/contracts/ERC4626Adapter.sol +++ b/packages/ovault-composer/contracts/ERC4626Adapter.sol @@ -21,6 +21,7 @@ contract ERC4626Adapter is IERC4626Adapter, IERC20 { string public symbol; uint8 private immutable _underlyingDecimals; + uint8 private immutable _shareDecimals; /** * @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC-20 or ERC-777). @@ -28,6 +29,8 @@ contract ERC4626Adapter is IERC4626Adapter, IERC20 { constructor(address asset_, address share_) { (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_); _underlyingDecimals = success ? assetDecimals : 18; + _shareDecimals = IERC20Metadata(share_).decimals(); + _asset = IERC20(asset_); _share = IERC20MintBurnExtension(share_); @@ -49,17 +52,6 @@ contract ERC4626Adapter is IERC4626Adapter, IERC20 { return (false, 0); } - /** - * @dev Decimals are computed by adding the decimal offset on top of the underlying asset's decimals. This - * "original" value is cached during construction of the vault contract. If this read operation fails (e.g., the - * asset has not been created yet), a default of 18 is used to represent the underlying asset's decimals. - * - * See {IERC20Metadata-decimals}. - */ - function decimals() public view virtual returns (uint8) { - return _underlyingDecimals + _decimalsOffset(); - } - /** @dev See {IERC4626-asset}. */ function asset() public view virtual returns (address) { return address(_asset); @@ -70,6 +62,10 @@ contract ERC4626Adapter is IERC4626Adapter, IERC20 { return address(_share); } + function decimals() public view virtual returns (uint8) { + return _shareDecimals; + } + /// @dev Adding to proxy the share token's total supply function totalSupply() public view virtual returns (uint256) { return IERC20(share()).totalSupply(); @@ -286,6 +282,6 @@ contract ERC4626Adapter is IERC4626Adapter, IERC20 { } function _decimalsOffset() internal view virtual returns (uint8) { - return 0; + return _shareDecimals - _underlyingDecimals; } } diff --git a/packages/ovault-composer/contracts/OVaultComposer.sol b/packages/ovault-composer/contracts/OVaultComposer.sol new file mode 100644 index 0000000000..d2c3cf0ec3 --- /dev/null +++ b/packages/ovault-composer/contracts/OVaultComposer.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20MintBurnExtension } from "./interfaces/IERC20MintBurnExtension.sol"; +import { IOVault } from "./interfaces/IOVault.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 { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; + +import { IOVaultComposer, FailedMessage, FailedState } from "./interfaces/IOVaultComposer.sol"; +import { IOVault } from "./interfaces/IOVault.sol"; +import { IERC4626Adapter } from "./interfaces/IERC4626Adapter.sol"; + +contract OVaultComposer is IOVaultComposer, ReentrancyGuard { + using OFTComposeMsgCodec for bytes; + using OFTComposeMsgCodec for bytes32; + + address public immutable ASSET_OFT; + address public immutable SHARE_OFT; + address public immutable OVAULT; + address public immutable ENDPOINT; + + mapping(bytes32 guid => FailedMessage) public failedMessages; + + constructor(address _ovault) { + address share = IERC4626Adapter(_ovault).share(); + address asset = IERC4626Adapter(_ovault).asset(); + if (!IERC20MintBurnExtension(share).ERC4626AdapterCompliant()) { + revert IOVault.ShareNotERC4626AdapterCompliant(); + } + + OVAULT = _ovault; + SHARE_OFT = IOVault(_ovault).SHARE_OFT(); + ASSET_OFT = IOVault(_ovault).ASSET_OFT(); + ENDPOINT = address(IOAppCore(ASSET_OFT).endpoint()); + + // Approve the adapter to spend the share tokens held by this contract + IERC20(share).approve(OVAULT, type(uint256).max); + IERC20(asset).approve(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 issues surrounding LayerZero config. This quoteSend catches issues like: invalid peer, dvn config, etc. + try this.validateTargetOFTConfig(oft, sendParam) {} catch (bytes memory errMsg) { + /// @dev When erroring out we want to NOT make a swap and the user can only go back to the source chain. + failedMessages[_guid] = FailedMessage(address(0), sendParam, _refundOFT, refundSendParam); + emit GenericError(_guid, oft, errMsg); + 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 Dirty swapping amountLD and minAmountLD to 1e18 and 0 to avoid Slippage issue on the target OFT quoteSend() + function validateTargetOFTConfig(address _oft, SendParam memory _sendParam) external view { + _sendParam.amountLD = 1e18; + _sendParam.minAmountLD = 0; + + IOFT(_oft).quoteSend(_sendParam, false); + } + + /// @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); + _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 == ASSET_OFT) { + vaultAmount = IERC4626Adapter(OVAULT).deposit(_amount, address(this)); + } else { + vaultAmount = IERC4626Adapter(OVAULT).redeem(_amount, address(this), address(this)); + } + } + + /// @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/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol new file mode 100644 index 0000000000..841979f0f5 --- /dev/null +++ b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol @@ -0,0 +1,66 @@ +// 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 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); + + /// ========================== Error Messages ===================================== + error InvalidAdapterMesh(); + error InvalidOFTMesh(); + + 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 validateTargetOFTConfig(address _oft, SendParam memory _sendParam) external view; + + 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/test/composer/OVaultComposer_Base.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol new file mode 100644 index 0000000000..c7ae72f144 --- /dev/null +++ b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol @@ -0,0 +1,165 @@ +// 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 "../utils/mocks/MockOFT.sol"; +import { MockOFTMintBurn } from "../utils/mocks/MockOFTMintBurn.sol"; +import { MockOVault } from "../utils/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; + MockOFTMintBurn 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 + assetOFT_arb = new MockOFT("arbERC20", "arbERC20", address(endpoints[ARB_EID]), address(this)); + assetOFT_eth = new MockOFT("ethERC20", "ethERC20", address(endpoints[ETH_EID]), address(this)); + shareOFT_eth = new MockOFT("ethERC20", "ethERC20", address(endpoints[ETH_EID]), address(this)); + assetOFT_pol = new MockOFT("polERC20", "polERC20", address(endpoints[POL_EID]), address(this)); + shareOFT_pol = new MockOFT("polERC20", "polERC20", address(endpoints[POL_EID]), address(this)); + + /// @dev Deploy the Asset OFT Mint Burn + shareOFT_arb = new MockOFTMintBurn("arbERC20", "arbERC20", address(endpoints[ARB_EID]), address(this)); + + oVault_arb = new MockOVault(assetOFT_arb, shareOFT_arb); + OVaultComposerArb = new OVaultComposer(address(oVault_arb)); + + vm.label(address(assetOFT_arb), "AssetOFT::arb"); + vm.label(address(shareOFT_arb), "ShareOFT::arb"); + vm.label(address(assetOFT_eth), "AssetOFT::eth"); + vm.label(address(shareOFT_eth), "ShareOFT::eth"); + vm.label(address(oVault_arb), "OVault::arb"); + vm.label(address(OVaultComposerArb), "OVaultComposer::arb"); + + // 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); + + address[] memory usdt0OFTs = new address[](subMeshSize); + usdt0OFTs[0] = address(shareOFT_eth); + usdt0OFTs[1] = address(shareOFT_arb); + usdt0OFTs[2] = address(shareOFT_pol); + this.wireOApps(usdt0OFTs); + + deal(arbExecutor, INITIAL_BALANCE); + deal(arbEndpoint, INITIAL_BALANCE); + + shareOFT_arb.setSuperUser(address(oVault_arb), true); + } + + 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; + + shareOFT_arb.setSuperUser(address(this), true); + shareOFT_arb.mint(address(0xbeef), mintShares); + assetOFT_arb.mint(address(oVault_arb), mintAssets); + shareOFT_arb.setSuperUser(address(this), false); + } + + 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/test/composer/OVaultComposer_E2E.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_E2E.t.sol new file mode 100644 index 0000000000..004eeeb60c --- /dev/null +++ b/packages/ovault-composer/test/composer/OVaultComposer_E2E.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +// OApp imports +import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol"; +import { SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; + +import { OVaultComposerBaseTest } from "./OVaultComposer_Base.t.sol"; + +import { IOVaultComposer, FailedState } from "../../contracts/interfaces/IOVaultComposer.sol"; +import { OVaultComposer } from "../../contracts/OVaultComposer.sol"; + +import { console } from "forge-std/console.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626Adapter } from "../../contracts/interfaces/IERC4626Adapter.sol"; +import { IOFT } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; +import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; +import { ILayerZeroEndpointV2 } from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroEndpointV2.sol"; + +import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol"; + +contract OVaultComposerE2ETest is OVaultComposerBaseTest { + using OptionsBuilder for bytes; + + /// @dev Not profiled + uint128 constant lzReceiveGasValue = 2_000_000; + uint128 constant lzComposeGasValue = 2_000_000; + + /// @dev Seems to consume about 2.2 gwei + uint128 constant lzComposeMsgValue = 3 gwei; + + 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/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol new file mode 100644 index 0000000000..e0c447668e --- /dev/null +++ b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +// OApp imports +import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol"; +import { SendParam } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; + +import { OVaultComposerBaseTest } from "./OVaultComposer_Base.t.sol"; + +import { IOVaultComposer, FailedState } from "../../contracts/interfaces/IOVaultComposer.sol"; +import { OVaultComposer } from "../../contracts/OVaultComposer.sol"; + +import { console } from "forge-std/console.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626Adapter } from "../../contracts/interfaces/IERC4626Adapter.sol"; +import { IOFT } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; +import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol"; + +contract OVaultComposerUnitTest is OVaultComposerBaseTest { + using OptionsBuilder for bytes; + + function setUp() public virtual override { + super.setUp(); + } + + function test_deployment() public view { + assertEq(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_onlyOFTMesh(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(shareOFT_arb)); + emit IERC20.Transfer(address(0), address(OVaultComposerArb), TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(oVault_arb)); + emit IERC4626Adapter.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(shareOFT_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(shareOFT_arb.totalSupply(), 0); + } + + 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); + + bytes memory errMsg = abi.encodeWithSelector(IOAppCore.NoPeer.selector, BAD_EID); + vm.expectEmit(address(OVaultComposerArb)); + emit IOVaultComposer.GenericError(guid, address(shareOFT_arb), errMsg); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(shareOFT_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(shareOFT_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(shareOFT_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(shareOFT_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(shareOFT_arb)); + emit IERC20.Transfer(address(0), address(OVaultComposerArb), TOKENS_TO_SEND); + + vm.expectEmit(true, true, true, true, address(oVault_arb)); + emit IERC4626Adapter.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(shareOFT_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(shareOFT_arb.totalSupply(), shareOFT_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); + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + TOKENS_TO_SEND * 2, + 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 * 2 + ); + 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(shareOFT_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(shareOFT_arb.totalSupply(), shareOFT_arb.balanceOf(address(0xbeef)), mintShares); + } +}