From 14b2bdccfac63c0f8b3eb90c92982ca860035117 Mon Sep 17 00:00:00 2001 From: shankar Date: Tue, 17 Jun 2025 18:49:59 +0000 Subject: [PATCH 01/19] chore: tentative interface for IOVaultCompose Signed-off-by: shankar --- .../contracts/interfaces/IOVaultComposer.sol | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol diff --git a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol new file mode 100644 index 0000000000..ebf154f405 --- /dev/null +++ b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol @@ -0,0 +1,48 @@ +// 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; + address refundOFT; + SendParam sendParam; +} + +interface IOVaultComposer is IOAppComposer { + /// ========================== EVENTS ===================================== + event DecodeFailed(bytes32 indexed guid, address indexed oft, bytes message); + event SlippageEncountered(uint256 amountLD, uint256 minAmountLD); + 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); + + /// ========================== 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 InvalidSendParam(SendParam sendParam); + 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); + function OPTIMISTICALLY_CONVERT_TOKENS() external view returns (bool); + + /// ========================== FUNCTIONS ===================================== + function executeOVaultAction(address _oft, uint256 _amount, uint256 _minAmountLD) external; + + function refund(bytes32 guid, bytes memory extraOptions) external payable; + function retry(bytes32 guid, bytes memory extraOptions) external payable; + function send(address _oft, SendParam memory _sendParam) external payable; + + receive() external payable; +} From f52916a1fee8f6ceaead934df203392b6e59716a Mon Sep 17 00:00:00 2001 From: shankar Date: Tue, 17 Jun 2025 18:50:17 +0000 Subject: [PATCH 02/19] feat: base implementation of OVault Composer Signed-off-by: shankar --- .../contracts/OVaultComposer.sol | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/ovault-composer/contracts/OVaultComposer.sol diff --git a/packages/ovault-composer/contracts/OVaultComposer.sol b/packages/ovault-composer/contracts/OVaultComposer.sol new file mode 100644 index 0000000000..62c5bcb7f2 --- /dev/null +++ b/packages/ovault-composer/contracts/OVaultComposer.sol @@ -0,0 +1,182 @@ +// 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 } 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; + + address public immutable ASSET_OFT; + address public immutable SHARE_OFT; + address public immutable OVAULT; + address public immutable ENDPOINT; + + bool public immutable OPTIMISTICALLY_CONVERT_TOKENS; + + /// @notice There are 3 states a failed message can be in: + /// @notice 1. Failed upon entering the composer - FailedMessage.oft == address(0) && FailedMessage.refundOFT == address(0) + /// @notice 2. Failed to decode the message - FailedMessage.oft == address(0) && FailedMessage.refundOFT != address(0) + /// @notice 3. Failed to send the message to the target OFT - FailedMessage.oft != address(0) && FailedMessage.refundOFT != address(0) + /// + /// @dev State 1 needs lzCompose() to be re-executed + /// @dev State 2 can only be refunded back to the source chain + /// @dev State 3 can be refunded back to the source chain or retried with more gas + mapping(bytes32 guid => FailedMessage) public failedMessages; + + constructor(address _ovault, bool _optimisticallyConvertTokens) { + 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); + + OPTIMISTICALLY_CONVERT_TOKENS = _optimisticallyConvertTokens; + } + + 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); // srcEid + refundSendParam.to = OFTComposeMsgCodec.composeFrom(_message); // srcSender + 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; + } 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), _refundOFT, refundSendParam); + emit DecodeFailed(_guid, oft, sendParamEncoded); + return; + } + + /// @dev If the composer is deployed with `OPTIMISTICALLY_CONVERT_TOKENS` set to TRUE then we will ALWAYS make the vault trade and if it errors our on OApp config the user can only retry and go ahead to the target chain since they have the target token. + /// @dev If the composer is deployed with `OPTIMISTICALLY_CONVERT_TOKENS` set to FALSE then we will early exit and the user can only go back to the source chain as they have the source token. + if (!OPTIMISTICALLY_CONVERT_TOKENS) { + /// @dev This quoteSend catches issues like: invalid peer or dvn config, etc. + try IOFT(oft).quoteSend(sendParam, false) {} catch { + /// @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), _refundOFT, refundSendParam); + return; + } + } + + try this.executeOVaultAction(_refundOFT, amount, sendParam.minAmountLD) {} catch { + failedMessages[_guid] = FailedMessage(address(0), _refundOFT, refundSendParam); + emit SlippageEncountered(amount, sendParam.minAmountLD); + 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, address(0), sendParam); + 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)); + } + + function executeOVaultAction(address _oft, uint256 _amount, uint256 _minAmountLD) external nonReentrant { + if (msg.sender != address(this)) revert OnlySelf(msg.sender); + + uint256 vaultAmount; + if (_oft == ASSET_OFT) { + vaultAmount = IERC4626Adapter(OVAULT).deposit(_amount, address(this)); + } else { + vaultAmount = IERC4626Adapter(OVAULT).redeem(_amount, address(this), address(this)); + } + + if (vaultAmount < _minAmountLD) { + /// @dev Will rollback on this function's storage changes (trade does not happen) + revert NotEnoughTargetTokens(vaultAmount, _minAmountLD); + } + } + + /// @dev External call for try...catch logic in lzCompose() + function send(address _oft, SendParam memory _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 (failedMessage.refundOFT == address(0)) revert InvalidSendParam(refundSendParam); + + 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 (failedMessage.oft == address(0)) revert InvalidSendParam(failedMessage.sendParam); + + SendParam memory sendParam = failedMessage.sendParam; + + sendParam.extraOptions = _extraOptions; + + delete failedMessages[_guid]; + _send(failedMessage.oft, sendParam); + emit Retried(_guid, failedMessage.oft); + } + + function _send(address _oft, SendParam memory _sendParam) internal { + IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); + } + + receive() external payable {} +} From 41e2725b8ba1ec791a3dc32fa872d67c74ae845b Mon Sep 17 00:00:00 2001 From: shankar Date: Tue, 17 Jun 2025 18:51:02 +0000 Subject: [PATCH 03/19] test: creating testint framework for the composer Signed-off-by: shankar --- .../test/composer/OVaultComposer_Base.t.sol | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol 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..78536aca28 --- /dev/null +++ b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol @@ -0,0 +1,111 @@ +// 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; + + uint32 public constant ETH_EID = 1; + uint32 public constant ARB_EID = 2; + + MockOFT public assetOFT_arb; + MockOFTMintBurn public shareOFT_arb; + + MockOFT public assetOFT_eth; + MockOFT public shareOFT_eth; + + 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(2, 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)); + + /// @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), true); + + 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[](2); + nativeMeshOFTs[0] = address(assetOFT_eth); + nativeMeshOFTs[1] = address(assetOFT_arb); + this.wireOApps(nativeMeshOFTs); + + address[] memory usdt0OFTs = new address[](2); + usdt0OFTs[0] = address(shareOFT_arb); + usdt0OFTs[1] = address(shareOFT_eth); + 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 _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"); + } +} From 894855b14fcd0ff3cd6398a995f396f15359c7c4 Mon Sep 17 00:00:00 2001 From: shankar Date: Tue, 17 Jun 2025 18:51:25 +0000 Subject: [PATCH 04/19] test: happy path testing works Signed-off-by: shankar --- .../test/composer/OVaultComposer_Unit.t.sol | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol 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..8eed5fe1fa --- /dev/null +++ b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol @@ -0,0 +1,85 @@ +// 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 } from "../../contracts/interfaces/IOVaultComposer.sol"; + +import { console } from "forge-std/console.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626Adapter } from "../../contracts/interfaces/IERC4626Adapter.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)); + assertEq(OVaultComposerArb.OPTIMISTICALLY_CONVERT_TOKENS(), true); + } + + 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( + ETH_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(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), TOKENS_TO_SEND); + assertEq(shareOFT_arb.totalSupply(), 0); + } +} From 93335bc8a5f61311bc14ee36f73207bf3adb9c40 Mon Sep 17 00:00:00 2001 From: shankar Date: Tue, 17 Jun 2025 19:08:33 +0000 Subject: [PATCH 05/19] emit refund OFT address on DecodeFailed and generic event for quoteSend error Signed-off-by: shankar --- packages/ovault-composer/contracts/OVaultComposer.sol | 5 +++-- .../ovault-composer/contracts/interfaces/IOVaultComposer.sol | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ovault-composer/contracts/OVaultComposer.sol b/packages/ovault-composer/contracts/OVaultComposer.sol index 62c5bcb7f2..ba3a9b14c7 100644 --- a/packages/ovault-composer/contracts/OVaultComposer.sol +++ b/packages/ovault-composer/contracts/OVaultComposer.sol @@ -85,7 +85,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @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), _refundOFT, refundSendParam); - emit DecodeFailed(_guid, oft, sendParamEncoded); + emit DecodeFailed(_guid, _refundOFT, sendParamEncoded); return; } @@ -93,9 +93,10 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev If the composer is deployed with `OPTIMISTICALLY_CONVERT_TOKENS` set to FALSE then we will early exit and the user can only go back to the source chain as they have the source token. if (!OPTIMISTICALLY_CONVERT_TOKENS) { /// @dev This quoteSend catches issues like: invalid peer or dvn config, etc. - try IOFT(oft).quoteSend(sendParam, false) {} catch { + try IOFT(oft).quoteSend(sendParam, false) {} 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), _refundOFT, refundSendParam); + emit GenericError(_guid, oft, errMsg); return; } } diff --git a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol index ebf154f405..24253d6a57 100644 --- a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol @@ -18,6 +18,7 @@ interface IOVaultComposer is IOAppComposer { 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(); From 674ce6383cda5d6a840e49a4c9172242d502c508 Mon Sep 17 00:00:00 2001 From: shankar Date: Tue, 17 Jun 2025 19:09:02 +0000 Subject: [PATCH 06/19] test: Using {A,B,C} as a testing mesh Signed-off-by: shankar --- .../test/composer/OVaultComposer_Base.t.sol | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol index 78536aca28..9b9b93cecb 100644 --- a/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol +++ b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol @@ -23,8 +23,12 @@ import { TestHelperOz5 } from "@layerzerolabs/test-devtools-evm-foundry/contract 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; @@ -32,6 +36,9 @@ contract OVaultComposerBaseTest is TestHelperOz5 { MockOFT public assetOFT_eth; MockOFT public shareOFT_eth; + MockOFT public assetOFT_pol; + MockOFT public shareOFT_pol; + MockOVault public oVault_arb; OVaultComposer public OVaultComposerArb; @@ -47,7 +54,7 @@ contract OVaultComposerBaseTest is TestHelperOz5 { function setUp() public virtual override { super.setUp(); - setUpEndpoints(2, LibraryType.UltraLightNode); + setUpEndpoints(subMeshSize, LibraryType.UltraLightNode); arbEndpoint = address(endpoints[ARB_EID]); @@ -55,6 +62,8 @@ contract OVaultComposerBaseTest is TestHelperOz5 { 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)); @@ -70,14 +79,16 @@ contract OVaultComposerBaseTest is TestHelperOz5 { vm.label(address(OVaultComposerArb), "OVaultComposer::arb"); // config and wire the ofts - address[] memory nativeMeshOFTs = new address[](2); + 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[](2); - usdt0OFTs[0] = address(shareOFT_arb); - usdt0OFTs[1] = address(shareOFT_eth); + 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); @@ -100,6 +111,20 @@ contract OVaultComposerBaseTest is TestHelperOz5 { ); } + 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 _randomGUID() internal view returns (bytes32) { return bytes32(vm.randomBytes(32)); } From fad7c5818ea32693aeb62b9172d16d97adb676c6 Mon Sep 17 00:00:00 2001 From: shankar Date: Tue, 17 Jun 2025 19:09:13 +0000 Subject: [PATCH 07/19] test for refund Signed-off-by: shankar --- .../test/composer/OVaultComposer_Unit.t.sol | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol index 8eed5fe1fa..71ab40a0f4 100644 --- a/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol +++ b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol @@ -45,7 +45,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); SendParam memory internalSendParam = SendParam( - ETH_EID, + POL_EID, addressToBytes32(userA), TOKENS_TO_SEND, 0, @@ -82,4 +82,29 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { 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(); + deal(address(assetOFT_arb), 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, ""); + + (address oft, address refundOFT, SendParam memory sendParam) = 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(sendParam.dstEid, ETH_EID, "retry dstEid should be ETH_EID"); + assertEq(sendParam.to, addressToBytes32(userA), "retry to should be userA"); + 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, "", "retry extraOptions should be empty"); + } } From 444cdc7ad7dc4ebda73ddea16a720fb78f610b82 Mon Sep 17 00:00:00 2001 From: shankar Date: Tue, 17 Jun 2025 22:16:45 +0000 Subject: [PATCH 08/19] chore: hub chain withdraw mechanism on retry Signed-off-by: shankar --- .../contracts/OVaultComposer.sol | 18 ++++++++++++++++-- .../contracts/interfaces/IOVaultComposer.sol | 6 +++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/ovault-composer/contracts/OVaultComposer.sol b/packages/ovault-composer/contracts/OVaultComposer.sol index ba3a9b14c7..09748dc046 100644 --- a/packages/ovault-composer/contracts/OVaultComposer.sol +++ b/packages/ovault-composer/contracts/OVaultComposer.sol @@ -16,6 +16,7 @@ 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; @@ -151,7 +152,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { function refund(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { FailedMessage memory failedMessage = failedMessages[_guid]; SendParam memory refundSendParam = failedMessage.sendParam; - if (failedMessage.refundOFT == address(0)) revert InvalidSendParam(refundSendParam); + if (failedMessage.refundOFT == address(0)) revert CanNotRefund(_guid); refundSendParam.extraOptions = _extraOptions; @@ -164,7 +165,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @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 (failedMessage.oft == address(0)) revert InvalidSendParam(failedMessage.sendParam); + if (failedMessage.oft == address(0)) revert CanNotRetry(_guid); SendParam memory sendParam = failedMessage.sendParam; @@ -175,6 +176,19 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { emit Retried(_guid, failedMessage.oft); } + function withdrawToRecipient(bytes32 _guid) external { + FailedMessage memory failedMessage = failedMessages[_guid]; + if (failedMessage.oft == address(0)) revert CanNotWithdraw(_guid); + + SendParam memory sendParam = failedMessage.sendParam; + address receiver = sendParam.to.bytes32ToAddress(); + + IERC20(failedMessage.oft).transfer(receiver, sendParam.amountLD); + + delete failedMessages[_guid]; + emit RecipientWithdrawn(_guid, failedMessage.oft); + } + function _send(address _oft, SendParam memory _sendParam) internal { IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); } diff --git a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol index 24253d6a57..6c7da6b882 100644 --- a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol @@ -19,6 +19,7 @@ interface IOVaultComposer is IOAppComposer { 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 RecipientWithdrawn(bytes32 indexed guid, address indexed oft); /// ========================== Error Messages ===================================== error InvalidAdapterMesh(); @@ -29,7 +30,9 @@ interface IOVaultComposer is IOAppComposer { error OnlyOFT(address oft); error OnlyAsset(address asset); error OnlyShare(address share); - error InvalidSendParam(SendParam sendParam); + error CanNotRefund(bytes32 guid); + error CanNotRetry(bytes32 guid); + error CanNotWithdraw(bytes32 guid); error NotEnoughTargetTokens(uint256 amountLD, uint256 minAmountLD); /// ========================== GLOBAL VARIABLE FUNCTIONS ===================================== @@ -43,6 +46,7 @@ interface IOVaultComposer is IOAppComposer { function refund(bytes32 guid, bytes memory extraOptions) external payable; function retry(bytes32 guid, bytes memory extraOptions) external payable; + function withdrawToRecipient(bytes32 guid) external; function send(address _oft, SendParam memory _sendParam) external payable; receive() external payable; From c63a164068290b4b3c7d4456792a6b031378aed3 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 02:28:17 +0000 Subject: [PATCH 09/19] feat: composer v2 Signed-off-by: shankar --- .../contracts/OVaultComposer.sol | 97 +++++++++++-------- .../contracts/interfaces/IOVaultComposer.sol | 27 ++++-- 2 files changed, 78 insertions(+), 46 deletions(-) diff --git a/packages/ovault-composer/contracts/OVaultComposer.sol b/packages/ovault-composer/contracts/OVaultComposer.sol index 09748dc046..7b5559104d 100644 --- a/packages/ovault-composer/contracts/OVaultComposer.sol +++ b/packages/ovault-composer/contracts/OVaultComposer.sol @@ -10,7 +10,7 @@ import { IOFT, SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/ import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol"; import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; -import { IOVaultComposer, FailedMessage } from "./interfaces/IOVaultComposer.sol"; +import { IOVaultComposer, FailedMessage, FailedState } from "./interfaces/IOVaultComposer.sol"; import { IOVault } from "./interfaces/IOVault.sol"; import { IERC4626Adapter } from "./interfaces/IERC4626Adapter.sol"; @@ -23,8 +23,6 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { address public immutable OVAULT; address public immutable ENDPOINT; - bool public immutable OPTIMISTICALLY_CONVERT_TOKENS; - /// @notice There are 3 states a failed message can be in: /// @notice 1. Failed upon entering the composer - FailedMessage.oft == address(0) && FailedMessage.refundOFT == address(0) /// @notice 2. Failed to decode the message - FailedMessage.oft == address(0) && FailedMessage.refundOFT != address(0) @@ -35,7 +33,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev State 3 can be refunded back to the source chain or retried with more gas mapping(bytes32 guid => FailedMessage) public failedMessages; - constructor(address _ovault, bool _optimisticallyConvertTokens) { + constructor(address _ovault) { address share = IERC4626Adapter(_ovault).share(); address asset = IERC4626Adapter(_ovault).asset(); if (!IERC20MintBurnExtension(share).ERC4626AdapterCompliant()) { @@ -50,8 +48,6 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { // 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); - - OPTIMISTICALLY_CONVERT_TOKENS = _optimisticallyConvertTokens; } function lzCompose( @@ -70,10 +66,10 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @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); // srcEid - refundSendParam.to = OFTComposeMsgCodec.composeFrom(_message); // srcSender + SendParam memory refundSendParam; + refundSendParam.dstEid = OFTComposeMsgCodec.srcEid(_message); + refundSendParam.to = OFTComposeMsgCodec.composeFrom(_message); refundSendParam.amountLD = amount; SendParam memory sendParam; @@ -82,29 +78,29 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { 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), _refundOFT, refundSendParam); + failedMessages[_guid] = FailedMessage(address(0), sendParam, _refundOFT, refundSendParam); emit DecodeFailed(_guid, _refundOFT, sendParamEncoded); return; } - /// @dev If the composer is deployed with `OPTIMISTICALLY_CONVERT_TOKENS` set to TRUE then we will ALWAYS make the vault trade and if it errors our on OApp config the user can only retry and go ahead to the target chain since they have the target token. - /// @dev If the composer is deployed with `OPTIMISTICALLY_CONVERT_TOKENS` set to FALSE then we will early exit and the user can only go back to the source chain as they have the source token. - if (!OPTIMISTICALLY_CONVERT_TOKENS) { - /// @dev This quoteSend catches issues like: invalid peer or dvn config, etc. - try IOFT(oft).quoteSend(sendParam, false) {} 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), _refundOFT, refundSendParam); - emit GenericError(_guid, oft, errMsg); - 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; } - try this.executeOVaultAction(_refundOFT, amount, sendParam.minAmountLD) {} catch { - failedMessages[_guid] = FailedMessage(address(0), _refundOFT, refundSendParam); - emit SlippageEncountered(amount, sendParam.minAmountLD); + /// @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; } @@ -114,7 +110,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } 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, address(0), sendParam); + failedMessages[_guid] = FailedMessage(oft, sendParam, address(0), refundSendParam); emit SendFailed(_guid, oft); return; } @@ -125,24 +121,34 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { sendParam = abi.decode(sendParamBytes, (SendParam)); } - function executeOVaultAction(address _oft, uint256 _amount, uint256 _minAmountLD) external nonReentrant { + function executeOVaultAction( + address _oft, + uint256 _amount, + SendParam calldata _sendParam + ) external nonReentrant returns (uint256 vaultAmount) { if (msg.sender != address(this)) revert OnlySelf(msg.sender); - uint256 vaultAmount; if (_oft == ASSET_OFT) { vaultAmount = IERC4626Adapter(OVAULT).deposit(_amount, address(this)); } else { vaultAmount = IERC4626Adapter(OVAULT).redeem(_amount, address(this), address(this)); } - if (vaultAmount < _minAmountLD) { + if (vaultAmount < _sendParam.minAmountLD) { /// @dev Will rollback on this function's storage changes (trade does not happen) - revert NotEnoughTargetTokens(vaultAmount, _minAmountLD); + revert NotEnoughTargetTokens(vaultAmount, _sendParam.minAmountLD); } } + 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 memory _sendParam) external payable nonReentrant { + function send(address _oft, SendParam calldata _sendParam) external payable nonReentrant { if (msg.sender != address(this)) revert OnlySelf(msg.sender); _send(_oft, _sendParam); } @@ -176,21 +182,34 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { emit Retried(_guid, failedMessage.oft); } - function withdrawToRecipient(bytes32 _guid) external { - FailedMessage memory failedMessage = failedMessages[_guid]; - if (failedMessage.oft == address(0)) revert CanNotWithdraw(_guid); + function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant {} - SendParam memory sendParam = failedMessage.sendParam; - address receiver = sendParam.to.bytes32ToAddress(); + function _send(address _oft, SendParam memory _sendParam) internal { + IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); + } - IERC20(failedMessage.oft).transfer(receiver, sendParam.amountLD); + function failedGuidState(bytes32 _guid) external view returns (FailedState) { + FailedMessage memory failedMessage = failedMessages[_guid]; - delete failedMessages[_guid]; - emit RecipientWithdrawn(_guid, failedMessage.oft); + 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; } - function _send(address _oft, SendParam memory _sendParam) internal { - IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); + function _previewOVaultAction(address _oft, uint256 _amount) internal view returns (uint256 vaultAmount) { + if (_oft == ASSET_OFT) { + vaultAmount = IERC4626Adapter(OVAULT).previewDeposit(_amount); + } else { + vaultAmount = IERC4626Adapter(OVAULT).previewRedeem(_amount); + } } receive() external payable {} diff --git a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol index 6c7da6b882..48c886df9c 100644 --- a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol @@ -6,20 +6,26 @@ import { IOFT, SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/ struct FailedMessage { address oft; - address refundOFT; SendParam sendParam; + address refundOFT; + SendParam refundSendParam; +} + +enum FailedState { + NotFound, + CanOnlyRefund, + CanRetryWithSwap, + CanOnlyRetry } interface IOVaultComposer is IOAppComposer { /// ========================== EVENTS ===================================== event DecodeFailed(bytes32 indexed guid, address indexed oft, bytes message); - event SlippageEncountered(uint256 amountLD, uint256 minAmountLD); 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); - event RecipientWithdrawn(bytes32 indexed guid, address indexed oft); /// ========================== Error Messages ===================================== error InvalidAdapterMesh(); @@ -39,15 +45,22 @@ interface IOVaultComposer is IOAppComposer { function ASSET_OFT() external view returns (address); function SHARE_OFT() external view returns (address); function ENDPOINT() external view returns (address); - function OPTIMISTICALLY_CONVERT_TOKENS() external view returns (bool); /// ========================== FUNCTIONS ===================================== - function executeOVaultAction(address _oft, uint256 _amount, uint256 _minAmountLD) external; + 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 withdrawToRecipient(bytes32 guid) external; - function send(address _oft, SendParam memory _sendParam) 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; } From bb4c5fdd038189346645c0b8824bc37acc7c2ae2 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 02:29:06 +0000 Subject: [PATCH 10/19] test: pass/fail testing for the external calls Signed-off-by: shankar --- .../test/composer/OVaultComposer_Base.t.sol | 18 +- .../test/composer/OVaultComposer_Unit.t.sol | 204 +++++++++++++++++- 2 files changed, 214 insertions(+), 8 deletions(-) diff --git a/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol index 9b9b93cecb..4f6414a073 100644 --- a/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol +++ b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol @@ -69,7 +69,7 @@ contract OVaultComposerBaseTest is TestHelperOz5 { 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), true); + OVaultComposerArb = new OVaultComposer(address(oVault_arb)); vm.label(address(assetOFT_arb), "AssetOFT::arb"); vm.label(address(shareOFT_arb), "ShareOFT::arb"); @@ -133,4 +133,20 @@ contract OVaultComposerBaseTest is TestHelperOz5 { 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_Unit.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol index 71ab40a0f4..3f16b24dac 100644 --- a/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol +++ b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol @@ -7,11 +7,14 @@ import { SendParam } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol" import { OVaultComposerBaseTest } from "./OVaultComposer_Base.t.sol"; -import { IOVaultComposer } from "../../contracts/interfaces/IOVaultComposer.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; @@ -24,7 +27,6 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(OVaultComposerArb.OVAULT(), address(oVault_arb)); assertEq(OVaultComposerArb.SHARE_OFT(), address(shareOFT_arb)); assertEq(OVaultComposerArb.ASSET_OFT(), address(assetOFT_arb)); - assertEq(OVaultComposerArb.OPTIMISTICALLY_CONVERT_TOKENS(), true); } function test_onlyEndpoint() public { @@ -79,13 +81,15 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { 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(); - deal(address(assetOFT_arb), address(OVaultComposerArb), TOKENS_TO_SEND); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); bytes memory invalidPayload = bytes("0x1234"); @@ -97,14 +101,200 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { vm.prank(arbEndpoint); OVaultComposerArb.lzCompose{ value: 1 ether }(address(assetOFT_arb), guid, composeMsg, arbExecutor, ""); - (address oft, address refundOFT, SendParam memory sendParam) = OVaultComposerArb.failedMessages(guid); + 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(sendParam.dstEid, ETH_EID, "retry dstEid should be ETH_EID"); - assertEq(sendParam.to, addressToBytes32(userA), "retry to should be userA"); + 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, "", "retry extraOptions should be empty"); + 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"); } } From 319b6259bfdd5c126124f268e825d0c3ecc7f79d Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 17:42:24 +0000 Subject: [PATCH 11/19] match ordering of FailedStates to the function that computes it in OVaultComposer Signed-off-by: shankar --- .../ovault-composer/contracts/interfaces/IOVaultComposer.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol index 48c886df9c..841979f0f5 100644 --- a/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-composer/contracts/interfaces/IOVaultComposer.sol @@ -14,8 +14,8 @@ struct FailedMessage { enum FailedState { NotFound, CanOnlyRefund, - CanRetryWithSwap, - CanOnlyRetry + CanOnlyRetry, + CanRetryWithSwap } interface IOVaultComposer is IOAppComposer { From 33ad6328043af3408ba692b4ad3c1e0d66a52264 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 17:42:51 +0000 Subject: [PATCH 12/19] chore: proxy share decimals Signed-off-by: shankar --- .../contracts/ERC4626Adapter.sol | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) 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; } } From 19d05e6e60f84aff602ab7c14588bae8f22938f4 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 17:43:07 +0000 Subject: [PATCH 13/19] feat: retryWithOVaultAction Signed-off-by: shankar --- .../contracts/OVaultComposer.sol | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/ovault-composer/contracts/OVaultComposer.sol b/packages/ovault-composer/contracts/OVaultComposer.sol index 7b5559104d..38a041dea2 100644 --- a/packages/ovault-composer/contracts/OVaultComposer.sol +++ b/packages/ovault-composer/contracts/OVaultComposer.sol @@ -158,7 +158,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { function refund(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { FailedMessage memory failedMessage = failedMessages[_guid]; SendParam memory refundSendParam = failedMessage.sendParam; - if (failedMessage.refundOFT == address(0)) revert CanNotRefund(_guid); + if (failedGuidState(_guid) != FailedState.CanOnlyRefund) revert CanNotRefund(_guid); refundSendParam.extraOptions = _extraOptions; @@ -171,7 +171,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @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 (failedMessage.oft == address(0)) revert CanNotRetry(_guid); + if (failedGuidState(_guid) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); SendParam memory sendParam = failedMessage.sendParam; @@ -182,13 +182,48 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { emit Retried(_guid, failedMessage.oft); } - function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant {} + 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; + + try this.executeOVaultAction(failedMessage.refundOFT, amountLd, sendParam) returns (uint256 vaultAmount) { + sendParam.amountLD = vaultAmount; + delete failedMessages[_guid]; + } catch (bytes memory errMsg) { + failedMessages[_guid] = FailedMessage( + failedMessage.oft, + sendParam, + address(0), + failedMessage.refundSendParam + ); + emit GenericError(_guid, failedMessage.oft, errMsg); + return; + } + + try this.send{ value: msg.value }(failedMessage.oft, sendParam) { + emit Sent(_guid, failedMessage.oft); + } catch { + failedMessages[_guid] = FailedMessage( + failedMessage.oft, + sendParam, + address(0), + failedMessage.refundSendParam + ); + emit SendFailed(_guid, failedMessage.oft); + return; + } + } function _send(address _oft, SendParam memory _sendParam) internal { IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); } - function failedGuidState(bytes32 _guid) external view returns (FailedState) { + function failedGuidState(bytes32 _guid) public view returns (FailedState) { FailedMessage memory failedMessage = failedMessages[_guid]; if (failedMessage.refundOFT == address(0) && failedMessage.oft == address(0)) { From 09c2b1472234a6e67fb22405f4ab1dc12195fc21 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 17:43:25 +0000 Subject: [PATCH 14/19] test: helper function to manipulate trade ratios Signed-off-by: shankar --- .../test/composer/OVaultComposer_Base.t.sol | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol index 4f6414a073..af96f181bb 100644 --- a/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol +++ b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol @@ -125,6 +125,19 @@ contract OVaultComposerBaseTest is TestHelperOz5 { ); } + function _setTradeRatioAssetToShare( + uint256 _assetNum, + uint256 _shareNum + ) internal returns (uint256 mintShares, uint256 mintAssets) { + mintShares = _shareNum * TOKENS_TO_SEND; + mintAssets = _assetNum * 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)); } From ebfc3785e7657338606f9467b7cf593f97dbc8c7 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 17:43:59 +0000 Subject: [PATCH 15/19] test: retry with ovault swap Signed-off-by: shankar --- .../test/composer/OVaultComposer_Unit.t.sol | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol index 3f16b24dac..39b32ebdad 100644 --- a/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol +++ b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol @@ -297,4 +297,47 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { 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 mintShares, uint256 mintAssets) = _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); + } } From 26e69a5276d5ef548847111d8c5b9eef229eaf57 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 18:25:58 +0000 Subject: [PATCH 16/19] fix return arg order of _setTradeRatioAssetToShare Signed-off-by: shankar --- .../ovault-composer/test/composer/OVaultComposer_Base.t.sol | 4 ++-- .../ovault-composer/test/composer/OVaultComposer_Unit.t.sol | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol index af96f181bb..c7ae72f144 100644 --- a/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol +++ b/packages/ovault-composer/test/composer/OVaultComposer_Base.t.sol @@ -128,9 +128,9 @@ contract OVaultComposerBaseTest is TestHelperOz5 { function _setTradeRatioAssetToShare( uint256 _assetNum, uint256 _shareNum - ) internal returns (uint256 mintShares, uint256 mintAssets) { - mintShares = _shareNum * TOKENS_TO_SEND; + ) 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); diff --git a/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol index 39b32ebdad..e0c447668e 100644 --- a/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol +++ b/packages/ovault-composer/test/composer/OVaultComposer_Unit.t.sol @@ -331,7 +331,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(shareOFT_arb.totalSupply(), 0); - (uint256 mintShares, uint256 mintAssets) = _setTradeRatioAssetToShare(1, 2); + (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); From ef82fadf16ac9c9f47f1de2c901ee2a4242ebe11 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 18:26:26 +0000 Subject: [PATCH 17/19] feat: E2E test of ETH -> POL Signed-off-by: shankar --- .../test/composer/OVaultComposer_E2E.t.sol | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 packages/ovault-composer/test/composer/OVaultComposer_E2E.t.sol 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" + ); + } +} From 831565f644724c83d0fbabad34a22f1e8f96efb9 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 18:37:02 +0000 Subject: [PATCH 18/19] docs + cleanup Signed-off-by: shankar --- .../contracts/OVaultComposer.sol | 59 ++++++------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/packages/ovault-composer/contracts/OVaultComposer.sol b/packages/ovault-composer/contracts/OVaultComposer.sol index 38a041dea2..230f285023 100644 --- a/packages/ovault-composer/contracts/OVaultComposer.sol +++ b/packages/ovault-composer/contracts/OVaultComposer.sol @@ -121,25 +121,21 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { 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); - - if (_oft == ASSET_OFT) { - vaultAmount = IERC4626Adapter(OVAULT).deposit(_amount, address(this)); - } else { - vaultAmount = IERC4626Adapter(OVAULT).redeem(_amount, address(this), address(this)); - } - + 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; @@ -182,6 +178,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { 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); @@ -191,38 +188,27 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { uint256 amountLd = failedMessage.refundSendParam.amountLD; - try this.executeOVaultAction(failedMessage.refundOFT, amountLd, sendParam) returns (uint256 vaultAmount) { - sendParam.amountLD = vaultAmount; - delete failedMessages[_guid]; - } catch (bytes memory errMsg) { - failedMessages[_guid] = FailedMessage( - failedMessage.oft, - sendParam, - address(0), - failedMessage.refundSendParam - ); - emit GenericError(_guid, failedMessage.oft, errMsg); - return; - } + delete failedMessages[_guid]; + sendParam.amountLD = _executeOVaultAction(failedMessage.refundOFT, amountLd); - try this.send{ value: msg.value }(failedMessage.oft, sendParam) { - emit Sent(_guid, failedMessage.oft); - } catch { - failedMessages[_guid] = FailedMessage( - failedMessage.oft, - sendParam, - address(0), - failedMessage.refundSendParam - ); - emit SendFailed(_guid, failedMessage.oft); - return; - } + _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]; @@ -238,14 +224,5 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { return FailedState.CanRetryWithSwap; } - - function _previewOVaultAction(address _oft, uint256 _amount) internal view returns (uint256 vaultAmount) { - if (_oft == ASSET_OFT) { - vaultAmount = IERC4626Adapter(OVAULT).previewDeposit(_amount); - } else { - vaultAmount = IERC4626Adapter(OVAULT).previewRedeem(_amount); - } - } - receive() external payable {} } From 5a96873024e6f9777f989142acb77cb132ef46f8 Mon Sep 17 00:00:00 2001 From: shankar Date: Wed, 18 Jun 2025 18:37:56 +0000 Subject: [PATCH 19/19] comments: remove invalid comment Signed-off-by: shankar --- packages/ovault-composer/contracts/OVaultComposer.sol | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/ovault-composer/contracts/OVaultComposer.sol b/packages/ovault-composer/contracts/OVaultComposer.sol index 230f285023..d2c3cf0ec3 100644 --- a/packages/ovault-composer/contracts/OVaultComposer.sol +++ b/packages/ovault-composer/contracts/OVaultComposer.sol @@ -23,14 +23,6 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { address public immutable OVAULT; address public immutable ENDPOINT; - /// @notice There are 3 states a failed message can be in: - /// @notice 1. Failed upon entering the composer - FailedMessage.oft == address(0) && FailedMessage.refundOFT == address(0) - /// @notice 2. Failed to decode the message - FailedMessage.oft == address(0) && FailedMessage.refundOFT != address(0) - /// @notice 3. Failed to send the message to the target OFT - FailedMessage.oft != address(0) && FailedMessage.refundOFT != address(0) - /// - /// @dev State 1 needs lzCompose() to be re-executed - /// @dev State 2 can only be refunded back to the source chain - /// @dev State 3 can be refunded back to the source chain or retried with more gas mapping(bytes32 guid => FailedMessage) public failedMessages; constructor(address _ovault) {