From 607200e7e57549e9b80ceb8becbb09e3ce7c7b91 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 17:19:34 +0000 Subject: [PATCH 01/30] using failedMessage.refundSendParam in refund Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 150755b6e6..461a59d313 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -160,7 +160,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @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; + SendParam memory refundSendParam = failedMessage.refundSendParam; if (failedGuidState(_guid) != FailedState.CanOnlyRefund) revert CanNotRefund(_guid); refundSendParam.extraOptions = _extraOptions; From f4f5703eb7cd2b7280d9f69ce2c76ad7b8b3a073 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 17:49:14 +0000 Subject: [PATCH 02/30] using transfer on hub for retry and refund and slippage check on retryWithSwap Signed-off-by: shankar --- .../ovault-evm/contracts/OVaultComposer.sol | 87 +++++++++++++------ 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 461a59d313..ef36662f50 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -140,15 +140,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver if (_sendParam.dstEid == HUB_EID) { - address _receiver = _sendParam.to.bytes32ToAddress(); - uint256 _amountLD = _sendParam.amountLD; - IERC20 token = IERC20(IOFT(_oft).token()); - token.transfer(_receiver, _amountLD); - if (msg.value > 0) { - (bool sent, ) = _receiver.call{ value: msg.value }(""); - require(sent, "Failed to send Ether"); - } - emit SentOnHub(_receiver, _oft, _amountLD); + _executeHubTransfer(_oft, _sendParam.to.bytes32ToAddress(), _sendParam.amountLD); return; } @@ -159,53 +151,77 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @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 { + if (failedGuidState(_guid) != FailedState.CanOnlyRefund) revert CanNotRefund(_guid); + FailedMessage memory failedMessage = failedMessages[_guid]; + delete failedMessages[_guid]; + SendParam memory refundSendParam = failedMessage.refundSendParam; - if (failedGuidState(_guid) != FailedState.CanOnlyRefund) revert CanNotRefund(_guid); + address refundOft = failedMessage.oft; + + /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver + if (refundSendParam.dstEid == HUB_EID) { + _executeHubTransfer(refundOft, refundSendParam.to.bytes32ToAddress(), refundSendParam.amountLD); + return; + } refundSendParam.extraOptions = _extraOptions; - delete failedMessages[_guid]; - _send(failedMessage.refundOFT, refundSendParam); - emit Refunded(_guid, failedMessage.refundOFT); + _send(refundOft, refundSendParam); + emit Refunded(_guid, refundOft); } /// @dev Permissionless function to retry the message with more gas /// @dev Failure case when there is a LayerZero config issue - ex: dvn config function retry(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { - FailedMessage memory failedMessage = failedMessages[_guid]; if (failedGuidState(_guid) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); + FailedMessage memory failedMessage = failedMessages[_guid]; + delete failedMessages[_guid]; + SendParam memory sendParam = failedMessage.sendParam; + address retryOFT = failedMessage.oft; + + /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver + if (sendParam.dstEid == HUB_EID) { + _executeHubTransfer(retryOFT, sendParam.to.bytes32ToAddress(), sendParam.amountLD); + return; + } sendParam.extraOptions = _extraOptions; - delete failedMessages[_guid]; - _send(failedMessage.oft, sendParam); - emit Retried(_guid, failedMessage.oft); + _send(retryOFT, sendParam); + emit Retried(_guid, retryOFT); } /// @dev Retry mechanism for transactions that failed due to slippage. This can revert. /// @dev Failure case when there is a LayerZero config issue - ex: dvn config function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable { - FailedMessage memory failedMessage = failedMessages[_guid]; if (failedGuidState(_guid) != FailedState.CanRetryWithSwap) revert CanNotRetry(_guid); + FailedMessage memory failedMessage = failedMessages[_guid]; + uint256 srcAmount = failedMessage.refundSendParam.amountLD; + delete failedMessages[_guid]; + SendParam memory sendParam = failedMessage.sendParam; - sendParam.extraOptions = _extraOptions; + address retryOFT = failedMessage.oft; - uint256 amountLd = failedMessage.refundSendParam.amountLD; + sendParam.amountLD = this.executeOVaultActionWithSlippageCheck( + failedMessage.refundOFT, + srcAmount, + sendParam.minAmountLD + ); - delete failedMessages[_guid]; - sendParam.amountLD = _executeOVaultAction(failedMessage.refundOFT, amountLd); + /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver + if (sendParam.dstEid == HUB_EID) { + _executeHubTransfer(retryOFT, sendParam.to.bytes32ToAddress(), sendParam.amountLD); + return; + } - _send(failedMessage.oft, sendParam); - emit Sent(_guid, failedMessage.oft); - } + sendParam.extraOptions = _extraOptions; - /// @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); + _send(retryOFT, sendParam); + emit Sent(_guid, retryOFT); } function _executeOVaultAction(address _oft, uint256 _amount) internal returns (uint256 vaultAmount) { @@ -216,6 +232,21 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } } + /// @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 _executeHubTransfer(address _oft, address _receiver, uint256 _amountLD) internal { + IERC20 token = IERC20(IOFT(_oft).token()); + token.transfer(_receiver, _amountLD); + if (msg.value > 0) { + (bool sent, ) = _receiver.call{ value: msg.value }(""); + require(sent, "Failed to send Ether"); + } + emit SentOnHub(_receiver, _oft, _amountLD); + } + /// @dev Helper to check if the target OFT does not have a peer set for the destination chain OR if our target chain is the not the same as the HUB chain function _isInvalidPeer(address _oft, uint32 _dstEid) internal view returns (bool) { return _dstEid != HUB_EID && IOAppCore(_oft).peers(_dstEid) == bytes32(0); From 281ef47a7549350578e2e4b4f6de79a49d511acd Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 17:58:45 +0000 Subject: [PATCH 03/30] OFT.send(..,_refundAddress) comment on tx.origin Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index ef36662f50..f7d2589479 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -233,6 +233,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } /// @dev Internal function to send the message to the target OFT + /// @dev In the event you're using a bundler or anything where the tx.origin is not the right receiver then this function will have to be overridden. function _send(address _oft, SendParam memory _sendParam) internal { IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); } From d8c727999c3d917a42477a4a7d267888ae807a8a Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 18:00:00 +0000 Subject: [PATCH 04/30] using SafeTransfer Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index f7d2589479..45c066cb3b 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.22; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import { IOFT, SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; @@ -15,6 +17,7 @@ import { IOVaultComposer, FailedMessage, FailedState } from "./interfaces/IOVaul contract OVaultComposer is IOVaultComposer, ReentrancyGuard { using OFTComposeMsgCodec for bytes; using OFTComposeMsgCodec for bytes32; + using SafeERC20 for IERC20; address public immutable ASSET_OFT; // any OFT address public immutable SHARE_OFT; // lockbox adapter @@ -240,7 +243,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { function _executeHubTransfer(address _oft, address _receiver, uint256 _amountLD) internal { IERC20 token = IERC20(IOFT(_oft).token()); - token.transfer(_receiver, _amountLD); + token.safeTransfer(_receiver, _amountLD); if (msg.value > 0) { (bool sent, ) = _receiver.call{ value: msg.value }(""); require(sent, "Failed to send Ether"); From 431a0084f0908dd3ab02d4366fd5e9a686bf211c Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 18:28:07 +0000 Subject: [PATCH 05/30] rename: CanRetryWithSwap to CanRetryWithSwapOrRefund Signed-off-by: shankar --- packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol index ff7cfaecf4..55187af7ba 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -15,7 +15,7 @@ enum FailedState { NotFound, CanOnlyRefund, CanOnlyRetry, - CanRetryWithSwap + CanRetryWithSwapOrRefund } interface IOVaultComposer is IOAppComposer { From e4076b15bd4b274b18351dc1ac7f7ea6e5e091ef Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 18:58:19 +0000 Subject: [PATCH 06/30] gas: optimized _failedGuidState() avoids reading from storage twice Signed-off-by: shankar --- .../ovault-evm/contracts/OVaultComposer.sol | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 45c066cb3b..125cd57a7c 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -154,9 +154,9 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @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 { - if (failedGuidState(_guid) != FailedState.CanOnlyRefund) revert CanNotRefund(_guid); - FailedMessage memory failedMessage = failedMessages[_guid]; + if (failedMessage.refundOFT != address(0)) revert CanNotRefund(_guid); + delete failedMessages[_guid]; SendParam memory refundSendParam = failedMessage.refundSendParam; @@ -177,9 +177,9 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev Permissionless function to retry the message with more gas /// @dev Failure case when there is a LayerZero config issue - ex: dvn config function retry(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { - if (failedGuidState(_guid) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); - FailedMessage memory failedMessage = failedMessages[_guid]; + if (_failedGuidState(failedMessage) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); + delete failedMessages[_guid]; SendParam memory sendParam = failedMessage.sendParam; @@ -200,9 +200,9 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev Retry mechanism for transactions that failed due to slippage. This can revert. /// @dev Failure case when there is a LayerZero config issue - ex: dvn config function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable { - if (failedGuidState(_guid) != FailedState.CanRetryWithSwap) revert CanNotRetry(_guid); - FailedMessage memory failedMessage = failedMessages[_guid]; + if (_failedGuidState(failedMessage) != FailedState.CanRetryWithSwapOrRefund) revert CanNotRetry(_guid); + uint256 srcAmount = failedMessage.refundSendParam.amountLD; delete failedMessages[_guid]; @@ -227,6 +227,12 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { emit Sent(_guid, retryOFT); } + /// @dev Helper to view the state of a failed message + function failedGuidState(bytes32 _guid) external view returns (FailedState) { + FailedMessage memory failedMessage = failedMessages[_guid]; + return _failedGuidState(failedMessage); + } + function _executeOVaultAction(address _oft, uint256 _amount) internal returns (uint256 vaultAmount) { if (_oft == address(ASSET_OFT)) { vaultAmount = OVAULT.deposit(_amount, address(this)); @@ -256,21 +262,18 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { return _dstEid != HUB_EID && IOAppCore(_oft).peers(_dstEid) == bytes32(0); } - /// @dev Helper to view the state of a failed message - function failedGuidState(bytes32 _guid) public view returns (FailedState) { - FailedMessage memory failedMessage = failedMessages[_guid]; - - if (failedMessage.refundOFT == address(0) && failedMessage.oft == address(0)) { + function _failedGuidState(FailedMessage memory _failedMessage) internal pure returns (FailedState) { + if (_failedMessage.refundOFT == address(0) && _failedMessage.oft == address(0)) { return FailedState.NotFound; } - if (failedMessage.refundOFT != address(0) && failedMessage.oft == address(0)) { + if (_failedMessage.refundOFT != address(0) && _failedMessage.oft == address(0)) { return FailedState.CanOnlyRefund; } - if (failedMessage.refundOFT == address(0) && failedMessage.oft != address(0)) { + if (_failedMessage.refundOFT == address(0) && _failedMessage.oft != address(0)) { return FailedState.CanOnlyRetry; } - return FailedState.CanRetryWithSwap; + return FailedState.CanRetryWithSwapOrRefund; } receive() external payable {} } From fae8a363758da70538241420769b67da42e841ef Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:13:52 +0000 Subject: [PATCH 07/30] using OZ's convertToAsset and convertToShare Signed-off-by: shankar --- packages/ovault-evm/contracts/OVault.sol | 14 -- .../contracts/OVaultUpgradeable.sol | 14 -- .../test/OVault_ERC4626_Equivalence.t.sol | 216 ------------------ .../test/composer/OVaultComposer_Base.t.sol | 19 ++ .../test/composer/OVaultComposer_E2E.t.sol | 5 +- .../test/composer/OVaultComposer_Unit.t.sol | 68 +++++- 6 files changed, 86 insertions(+), 250 deletions(-) diff --git a/packages/ovault-evm/contracts/OVault.sol b/packages/ovault-evm/contracts/OVault.sol index befcaae49b..98b9a5c7d7 100644 --- a/packages/ovault-evm/contracts/OVault.sol +++ b/packages/ovault-evm/contracts/OVault.sol @@ -12,18 +12,4 @@ contract OVault is ERC4626 { using SafeERC20 for IERC20; constructor(string memory name, string memory symbol, address asset) ERC4626(IERC20(asset)) ERC20(name, symbol) {} - - /// @dev Using solmate's implementation to work around rounding issues on initial minting - function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) { - uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. - - return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), rounding); - } - - /// @dev Using solmate's implementation to work around rounding issues on initial minting - function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view override returns (uint256) { - uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. - - return supply == 0 ? shares : shares.mulDiv(totalAssets(), supply, rounding); - } } diff --git a/packages/ovault-evm/contracts/OVaultUpgradeable.sol b/packages/ovault-evm/contracts/OVaultUpgradeable.sol index 842df83eac..174b5695fb 100644 --- a/packages/ovault-evm/contracts/OVaultUpgradeable.sol +++ b/packages/ovault-evm/contracts/OVaultUpgradeable.sol @@ -16,18 +16,4 @@ contract OVaultUpgradeable is ERC4626Upgradeable { constructor() { _disableInitializers(); } - - /// @dev Using solmate's implementation to work around rounding issues on initial minting - function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) { - uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. - - return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), rounding); - } - - /// @dev Using solmate's implementation to work around rounding issues on initial minting - function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view override returns (uint256) { - uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. - - return supply == 0 ? shares : shares.mulDiv(totalAssets(), supply, rounding); - } } diff --git a/packages/ovault-evm/test/OVault_ERC4626_Equivalence.t.sol b/packages/ovault-evm/test/OVault_ERC4626_Equivalence.t.sol index bb4556cb2b..4c57fa734c 100644 --- a/packages/ovault-evm/test/OVault_ERC4626_Equivalence.t.sol +++ b/packages/ovault-evm/test/OVault_ERC4626_Equivalence.t.sol @@ -126,222 +126,6 @@ contract OVaultERC4626EquivalenceTest is TestHelperOz5 { assertEq(assetOFT.balanceOf(alice), alicePreDepositBal); } - function test_ovault_MultipleMintDepositRedeemWithdraw() public { - // Scenario: - // A = Alice, B = Bob - // ________________________________________________________ - // | Vault shares | A share | A assets | B share | B assets | - // |========================================================| - // | 1. Alice mints 2000 shares (costs 2000 tokens) | - // |--------------|---------|----------|---------|----------| - // | 2000 | 2000 | 2000 | 0 | 0 | - // |--------------|---------|----------|---------|----------| - // | 2. Bob deposits 4000 tokens (mints 4000 shares) | - // |--------------|---------|----------|---------|----------| - // | 6000 | 2000 | 2000 | 4000 | 4000 | - // |--------------|---------|----------|---------|----------| - // | 3. Vault mutates by +3000 tokens... | - // | (simulated yield returned from strategy)... | - // |--------------|---------|----------|---------|----------| - // | 6000 | 2000 | 3000 | 4000 | 6000 | - // |--------------|---------|----------|---------|----------| - // | 4. Alice deposits 2000 tokens (mints 1333 shares) | - // |--------------|---------|----------|---------|----------| - // | 7333 | 3333 | 4999 | 4000 | 6000 | - // |--------------|---------|----------|---------|----------| - // | 5. Bob mints 2000 shares (costs 3001 assets) | - // | NOTE: Bob's assets spent got rounded up | - // | NOTE: Alice's vault assets got rounded up | - // |--------------|---------|----------|---------|----------| - // | 9333 | 3333 | 5000 | 6000 | 9000 | - // |--------------|---------|----------|---------|----------| - // | 6. Vault mutates by +3000 tokens... | - // | (simulated yield returned from strategy) | - // | NOTE: Vault holds 17001 tokens, but sum of | - // | assetsOf() is 17000. | - // |--------------|---------|----------|---------|----------| - // | 9333 | 3333 | 6071 | 6000 | 10929 | - // |--------------|---------|----------|---------|----------| - // | 7. Alice redeem 1333 shares (2428 assets) | - // |--------------|---------|----------|---------|----------| - // | 8000 | 2000 | 3643 | 6000 | 10929 | - // |--------------|---------|----------|---------|----------| - // | 8. Bob withdraws 2928 assets (1608 shares) | - // |--------------|---------|----------|---------|----------| - // | 6392 | 2000 | 3643 | 4392 | 8000 | - // |--------------|---------|----------|---------|----------| - // | 9. Alice withdraws 3643 assets (2000 shares) | - // | NOTE: Bob's assets have been rounded back up | - // |--------------|---------|----------|---------|----------| - // | 4392 | 0 | 0 | 4392 | 8001 | - // |--------------|---------|----------|---------|----------| - // | 10. Bob redeem 4392 shares (8001 tokens) | - // |--------------|---------|----------|---------|----------| - // | 0 | 0 | 0 | 0 | 0 | - // |______________|_________|__________|_________|__________| - - address alice = address(0xABCD); - address bob = address(0xDCBA); - - uint256 mutationassetAmount = 3000; - - assetOFT.mint(alice, 4000); - - vm.prank(alice); - assetOFT.approve(address(vault), 4000); - - assertEq(assetOFT.allowance(alice, address(vault)), 4000); - - assetOFT.mint(bob, 7001); - - vm.prank(bob); - assetOFT.approve(address(vault), 7001); - - assertEq(assetOFT.allowance(bob, address(vault)), 7001); - - // 1. Alice mints 2000 shares (costs 2000 tokens) - vm.prank(alice); - uint256 aliceassetAmount = vault.mint(2000, alice); - - uint256 aliceShareAmount = vault.previewDeposit(aliceassetAmount); - - // Expect to have received the requested mint amount. - assertEq(aliceShareAmount, 2000); - assertEq(vault.balanceOf(alice), aliceShareAmount); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), aliceassetAmount); - assertEq(vault.convertToShares(aliceassetAmount), vault.balanceOf(alice)); - - // Expect a 1:1 ratio before mutation. - assertEq(aliceassetAmount, 2000); - - // Sanity check. - assertEq(vault.totalSupply(), aliceShareAmount); - assertEq(vault.totalAssets(), aliceassetAmount); - - // 2. Bob deposits 4000 tokens (mints 4000 shares) - vm.prank(bob); - uint256 bobShareAmount = vault.deposit(4000, bob); - uint256 bobassetAmount = vault.previewWithdraw(bobShareAmount); - - // Expect to have received the requested asset amount. - assertEq(bobassetAmount, 4000); - assertEq(vault.balanceOf(bob), bobShareAmount); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), bobassetAmount); - assertEq(vault.convertToShares(bobassetAmount), vault.balanceOf(bob)); - - // Expect a 1:1 ratio before mutation. - assertEq(bobShareAmount, bobassetAmount); - - // Sanity check. - uint256 preMutationShareBal = aliceShareAmount + bobShareAmount; - uint256 preMutationBal = aliceassetAmount + bobassetAmount; - assertEq(vault.totalSupply(), preMutationShareBal); - assertEq(vault.totalAssets(), preMutationBal); - assertEq(vault.totalSupply(), 6000); - assertEq(vault.totalAssets(), 6000); - - // 3. Vault mutates by +3000 tokens... | - // (simulated yield returned from strategy)... - // The Vault now contains more tokens than deposited which causes the exchange rate to change. - // Alice share is 33.33% of the Vault, Bob 66.66% of the Vault. - // Alice's share count stays the same but the asset amount changes from 2000 to 3000. - // Bob's share count stays the same but the asset amount changes from 4000 to 6000. - assetOFT.mint(address(vault), mutationassetAmount); - assertEq(vault.totalSupply(), preMutationShareBal); - assertEq(vault.totalAssets(), preMutationBal + mutationassetAmount); - assertEq(vault.balanceOf(alice), aliceShareAmount); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), aliceassetAmount + (mutationassetAmount / 3) * 1); - assertEq(vault.balanceOf(bob), bobShareAmount); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), bobassetAmount + (mutationassetAmount / 3) * 2); - - // 4. Alice deposits 2000 tokens (mints 1333 shares) - vm.prank(alice); - vault.deposit(2000, alice); - - assertEq(vault.totalSupply(), 7333); - assertEq(vault.balanceOf(alice), 3333); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 4999); - assertEq(vault.balanceOf(bob), 4000); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 6000); - - // 5. Bob mints 2000 shares (costs 3001 assets) - // NOTE: Bob's assets spent got rounded up - // NOTE: Alices's vault assets got rounded up - vm.prank(bob); - vault.mint(2000, bob); - - assertEq(vault.totalSupply(), 9333); - assertEq(vault.balanceOf(alice), 3333); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 5000); - assertEq(vault.balanceOf(bob), 6000); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 9000); - - // Sanity checks: - // Alice and bob should have spent all their tokens now - assertEq(assetOFT.balanceOf(alice), 0); - assertEq(assetOFT.balanceOf(bob), 0); - // Assets in vault: 4k (alice) + 7k (bob) + 3k (yield) + 1 (round up) - assertEq(vault.totalAssets(), 14001); - - // 6. Vault mutates by +3000 tokens - // NOTE: Vault holds 17001 tokens, but sum of assetsOf() is 17000. - assetOFT.mint(address(vault), mutationassetAmount); - assertEq(vault.totalAssets(), 17001); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 6071); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 10929); - - // 7. Alice redeem 1333 shares (2428 assets) - vm.prank(alice); - vault.redeem(1333, alice, alice); - - assertEq(assetOFT.balanceOf(alice), 2428); - assertEq(vault.totalSupply(), 8000); - assertEq(vault.totalAssets(), 14573); - assertEq(vault.balanceOf(alice), 2000); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 3643); - assertEq(vault.balanceOf(bob), 6000); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 10929); - - // 8. Bob withdraws 2929 assets (1608 shares) - vm.prank(bob); - vault.withdraw(2929, bob, bob); - - assertEq(assetOFT.balanceOf(bob), 2929); - assertEq(vault.totalSupply(), 6392); - assertEq(vault.totalAssets(), 11644); - assertEq(vault.balanceOf(alice), 2000); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 3643); - assertEq(vault.balanceOf(bob), 4392); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 8000); - - // 9. Alice withdraws 3643 assets (2000 shares) - // NOTE: Bob's assets have been rounded back up - vm.prank(alice); - vault.withdraw(3643, alice, alice); - - assertEq(assetOFT.balanceOf(alice), 6071); - assertEq(vault.totalSupply(), 4392); - assertEq(vault.totalAssets(), 8001); - assertEq(vault.balanceOf(alice), 0); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 0); - assertEq(vault.balanceOf(bob), 4392); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 8001); - - // 10. Bob redeem 4392 shares (8001 tokens) - vm.prank(bob); - vault.redeem(4392, bob, bob); - assertEq(assetOFT.balanceOf(bob), 10930); - assertEq(vault.totalSupply(), 0); - assertEq(vault.totalAssets(), 0); - assertEq(vault.balanceOf(alice), 0); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 0); - assertEq(vault.balanceOf(bob), 0); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 0); - - // Sanity check - assertEq(assetOFT.balanceOf(address(vault)), 0); - } - function test_ovault_FailDepositWithNotEnoughApproval() public { assetOFT.mint(address(this), 0.5e18); assetOFT.approve(address(vault), 0.5e18); diff --git a/packages/ovault-evm/test/composer/OVaultComposer_Base.t.sol b/packages/ovault-evm/test/composer/OVaultComposer_Base.t.sol index abad402250..e1840ab0e8 100644 --- a/packages/ovault-evm/test/composer/OVaultComposer_Base.t.sol +++ b/packages/ovault-evm/test/composer/OVaultComposer_Base.t.sol @@ -139,6 +139,25 @@ contract OVaultComposerBaseTest is TestHelperOz5 { assetOFT_arb.mint(address(oVault_arb), mintAssets); } + function _removeDustWithOffset(uint256 _amount, int128 _offset) internal pure returns (uint256, uint256) { + uint256 amountWithOffset = _amount; + if (_offset < 0) { + uint256 modOffset = uint128(-1 * _offset); + // If offset is negative, we need to ensure we don't underflow + require(_amount >= modOffset, "Offset too large"); + amountWithOffset = _amount - modOffset; + } else { + // If offset is positive, we can safely add it + amountWithOffset = _amount + uint128(_offset); + } + return _removeDust(amountWithOffset); + } + + function _removeDust(uint256 _amount) internal pure returns (uint256, uint256) { + uint256 dust = _amount % 10 ** 12; + return (_amount - dust, dust); + } + function _randomGUID() internal view returns (bytes32) { return bytes32(vm.randomBytes(32)); } diff --git a/packages/ovault-evm/test/composer/OVaultComposer_E2E.t.sol b/packages/ovault-evm/test/composer/OVaultComposer_E2E.t.sol index 351fc8c98d..c343193779 100644 --- a/packages/ovault-evm/test/composer/OVaultComposer_E2E.t.sol +++ b/packages/ovault-evm/test/composer/OVaultComposer_E2E.t.sol @@ -36,7 +36,7 @@ contract OVaultComposerE2ETest is OVaultComposerBaseTest { } function test_E2E_ethereum_to_polygon() public { - uint256 shareTokensToReceive = TOKENS_TO_SEND * 2; + (uint256 shareTokensToReceive, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); deal(address(assetOFT_eth), userA, TOKENS_TO_SEND); @@ -105,10 +105,11 @@ contract OVaultComposerE2ETest is OVaultComposerBaseTest { address(this), "" ); + assertEq( assetOFT_arb.balanceOf(composerAddress), 0, - "composerAddress should have no tokens after lzCompose on arb" + "composerAddress should have the no tokens after lzCompose on arb" ); verifyPackets(POL_EID, addressToBytes32(address(shareOFT_pol))); diff --git a/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol index 504449a728..a58e52794d 100644 --- a/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol +++ b/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol @@ -239,7 +239,7 @@ 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.CanRetryWithSwap)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwapOrRefund)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); @@ -330,7 +330,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); - uint256 targetAmount = TOKENS_TO_SEND * 2; + (uint256 targetAmount, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); SendParam memory internalSendParam = SendParam( POL_EID, @@ -356,7 +356,7 @@ 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.CanRetryWithSwap)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwapOrRefund)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); @@ -369,8 +369,68 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), mintAssets + TOKENS_TO_SEND); + (uint256 _ovaultTotalSupply, ) = _removeDust(oVault_arb.totalSupply()); assertEq( - oVault_arb.totalSupply(), + _ovaultTotalSupply, + oVault_arb.balanceOf(address(0xbeef)) + oVault_arb.balanceOf(address(shareOFT_arb)), + mintShares + targetAmount + ); + } + + function test_lzCompose_slippage_retry_with_swap_failed_retains_transaction() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + (uint256 targetAmount, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + targetAmount, + OPTIONS_LZRECEIVE_2M, + "", + "" + ); + + bytes memory composePayload = abi.encode(internalSendParam); + bytes memory composeMsg = _createComposePayload(ETH_EID, composePayload, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + bytes memory errMsg = abi.encodeWithSelector( + IOVaultComposer.NotEnoughTargetTokens.selector, + TOKENS_TO_SEND, + targetAmount + ); + emit IOVaultComposer.OVaultError(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.CanRetryWithSwapOrRefund)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.expectRevert(); + OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwapOrRefund)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); + + OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), mintAssets + TOKENS_TO_SEND); + + (uint256 _ovaultTotalSupply, ) = _removeDust(oVault_arb.totalSupply()); + + assertEq( + _ovaultTotalSupply, oVault_arb.balanceOf(address(0xbeef)) + oVault_arb.balanceOf(address(shareOFT_arb)), mintShares + targetAmount ); From 69c03f0a0e194e3ff3c65b7472ca360fd4acfe34 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:19:43 +0000 Subject: [PATCH 08/30] chore: require OFTs and Vault to have the same tokens Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 8 ++++++++ .../ovault-evm/contracts/interfaces/IOVaultComposer.sol | 2 ++ 2 files changed, 10 insertions(+) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 125cd57a7c..b593d20674 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -36,6 +36,14 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { revert ShareOFTShouldBeLockboxAdapter(address(_shareOFT)); } + if (address(IOFT(_shareOFT).token()) != address(OVAULT)) { + revert ShareOFTInnerTokenShouldBeOVault(address(IOFT(_shareOFT).token()), address(OVAULT)); + } + + if (IOFT(_assetOFT).token() != OVAULT.asset()) { + revert AssetOFTInnerTokenShouldBeOvaultAsset(address(IOFT(_assetOFT).token()), address(OVAULT.asset())); + } + ENDPOINT = address(IOAppCore(ASSET_OFT).endpoint()); HUB_EID = ILayerZeroEndpointV2(ENDPOINT).eid(); diff --git a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol index 55187af7ba..c63f127f34 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -31,6 +31,8 @@ interface IOVaultComposer is IOAppComposer { /// ========================== Error Messages ===================================== error ShareOFTShouldBeLockboxAdapter(address share); + error AssetOFTInnerTokenShouldBeOvaultAsset(address assetInnerToken, address vaultAsset); + error ShareOFTInnerTokenShouldBeOVault(address shareInnerToken, address vaultToken); error OnlyEndpoint(address caller); error OnlySelf(address caller); From cea5b1d680decae42606a7eafdca54d5c3432104 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:21:10 +0000 Subject: [PATCH 09/30] fix comment #14 Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index b593d20674..2813524b9a 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -265,7 +265,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { emit SentOnHub(_receiver, _oft, _amountLD); } - /// @dev Helper to check if the target OFT does not have a peer set for the destination chain OR if our target chain is the not the same as the HUB chain + /// @dev Helper to check if the target OFT does not have a peer set for the destination chain OR if our target chain is not the same as the HUB chain function _isInvalidPeer(address _oft, uint32 _dstEid) internal view returns (bool) { return _dstEid != HUB_EID && IOAppCore(_oft).peers(_dstEid) == bytes32(0); } From 4cc35ebdc1680112a020c546d944bff87c2d7ef4 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:22:34 +0000 Subject: [PATCH 10/30] comments: make clear about slippage #11 Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 2813524b9a..07d6c8813e 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -251,6 +251,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev Internal function to send the message to the target OFT /// @dev In the event you're using a bundler or anything where the tx.origin is not the right receiver then this function will have to be overridden. + /// @dev Slippage check happens at the OFT for : amountLD >= sendParam.minAmountLD function _send(address _oft, SendParam memory _sendParam) internal { IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); } From adcfe5567b7f5a3cb9595e7070e55d4725926c99 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:25:08 +0000 Subject: [PATCH 11/30] remove inaccessible functions from interface #9 Signed-off-by: shankar --- .../ovault-evm/contracts/interfaces/IOVaultComposer.sol | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol index c63f127f34..05bc61797b 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -50,16 +50,9 @@ interface IOVaultComposer is IOAppComposer { function ENDPOINT() external view returns (address); /// ========================== FUNCTIONS ===================================== - function executeOVaultActionWithSlippageCheck( - address _oft, - uint256 _amount, - uint256 _minAmountLD - ) external returns (uint256 vaultAmount); - function refund(bytes32 guid, bytes memory extraOptions) external payable; function retry(bytes32 guid, bytes memory extraOptions) external payable; function retryWithSwap(bytes32 guid, bytes memory extraOptions) external payable; - function send(address _oft, SendParam calldata _sendParam) external payable; function failedGuidState(bytes32 guid) external view returns (FailedState); From 8dac57789a3c41054f8b7ad0e705944f46ef189d Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:29:34 +0000 Subject: [PATCH 12/30] improved comments on retry #7 Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 07d6c8813e..a579116a6b 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -183,7 +183,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } /// @dev Permissionless function to retry the message with more gas - /// @dev Failure case when there is a LayerZero config issue - ex: dvn config + /// @dev Failure case when there is a LayerZero config issue when a peer is set - ex: dvn config function retry(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { FailedMessage memory failedMessage = failedMessages[_guid]; if (_failedGuidState(failedMessage) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); @@ -206,7 +206,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } /// @dev Retry mechanism for transactions that failed due to slippage. This can revert. - /// @dev Failure case when there is a LayerZero config issue - ex: dvn config + /// @dev Failure case when there is a LayerZero config issue when a peer is set - ex: dvn config function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable { FailedMessage memory failedMessage = failedMessages[_guid]; if (_failedGuidState(failedMessage) != FailedState.CanRetryWithSwapOrRefund) revert CanNotRetry(_guid); From b7c7d54097ebd65dc6faafa9dae12e9f1261ff7e Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:32:53 +0000 Subject: [PATCH 13/30] adding nonReentrant on retryWithSwap #5 Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index a579116a6b..1821746266 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -207,7 +207,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev Retry mechanism for transactions that failed due to slippage. This can revert. /// @dev Failure case when there is a LayerZero config issue when a peer is set - ex: dvn config - function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable { + function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { FailedMessage memory failedMessage = failedMessages[_guid]; if (_failedGuidState(failedMessage) != FailedState.CanRetryWithSwapOrRefund) revert CanNotRetry(_guid); @@ -217,11 +217,13 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { SendParam memory sendParam = failedMessage.sendParam; address retryOFT = failedMessage.oft; - sendParam.amountLD = this.executeOVaultActionWithSlippageCheck( - failedMessage.refundOFT, - srcAmount, - sendParam.minAmountLD - ); + uint256 vaultAmount = _executeOVaultAction(failedMessage.refundOFT, srcAmount); + + if (vaultAmount < sendParam.minAmountLD) { + revert NotEnoughTargetTokens(vaultAmount, sendParam.minAmountLD); + } + + sendParam.amountLD = vaultAmount; /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver if (sendParam.dstEid == HUB_EID) { From 78e4aad46aade8dd136083a2ee31449d8c9b5552 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:35:57 +0000 Subject: [PATCH 14/30] remove unused imports #1 Signed-off-by: shankar --- packages/ovault-evm/contracts/OVault.sol | 11 +++++------ packages/ovault-evm/contracts/OVaultUpgradeable.sol | 6 ------ .../contracts/interfaces/IOVaultComposer.sol | 2 +- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/ovault-evm/contracts/OVault.sol b/packages/ovault-evm/contracts/OVault.sol index 98b9a5c7d7..48b86832e4 100644 --- a/packages/ovault-evm/contracts/OVault.sol +++ b/packages/ovault-evm/contracts/OVault.sol @@ -2,14 +2,13 @@ pragma solidity ^0.8.20; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; contract OVault is ERC4626 { - using Math for uint256; - using SafeERC20 for IERC20; - - constructor(string memory name, string memory symbol, address asset) ERC4626(IERC20(asset)) ERC20(name, symbol) {} + constructor( + string memory _name, + string memory _symbol, + address _asset + ) ERC4626(IERC20(_asset)) ERC20(_name, _symbol) {} } diff --git a/packages/ovault-evm/contracts/OVaultUpgradeable.sol b/packages/ovault-evm/contracts/OVaultUpgradeable.sol index 174b5695fb..ae1df08ef4 100644 --- a/packages/ovault-evm/contracts/OVaultUpgradeable.sol +++ b/packages/ovault-evm/contracts/OVaultUpgradeable.sol @@ -2,16 +2,10 @@ pragma solidity ^0.8.20; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; contract OVaultUpgradeable is ERC4626Upgradeable { - using SafeERC20 for IERC20; - using Math for uint256; - /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); diff --git a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol index 05bc61797b..b6aadb26fc 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -2,7 +2,7 @@ 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"; +import { SendParam } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; struct FailedMessage { address oft; From ff1673044ed55e3b2ad789a8fdf933ad91528b82 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 19:38:49 +0000 Subject: [PATCH 15/30] gas: do not store refundSendParam on retry #17 Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 3 ++- .../test/composer/OVaultComposer_Unit.t.sol | 13 +------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 1821746266..dd6c4031f4 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -116,9 +116,10 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { try this.send{ value: msg.value }(oft, sendParam) { emit Sent(_guid, oft); } catch { + SendParam memory emptySendParam; /// @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); + failedMessages[_guid] = FailedMessage(oft, sendParam, address(0), emptySendParam); emit SendFailed(_guid, oft); /// @dev This can be due to msg.value or layerzero config (dvn config, etc) return; } diff --git a/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol index a58e52794d..07e6b643d9 100644 --- a/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol +++ b/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol @@ -304,12 +304,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); - ( - address oft, - SendParam memory sendParam, - address refundOFT, - SendParam memory refundSendParam - ) = OVaultComposerArb.failedMessages(guid); + (address oft, SendParam memory sendParam, address refundOFT, ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(0), "refundOFT should be 0 - not possible"); assertEq(oft, address(shareOFT_arb), "retry oft should be shareOFT_arb"); @@ -318,12 +313,6 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { 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 { From e5c519375a6e6d26b954fbcd55627ee2120d3e35 Mon Sep 17 00:00:00 2001 From: shankar Date: Thu, 3 Jul 2025 21:20:37 +0000 Subject: [PATCH 16/30] update to match package changes Signed-off-by: shankar --- .../test/OVault_ERC4626_Equivalence.t.sol | 216 ------------------ .../test/composer/OVaultComposer_Base.t.sol | 19 ++ .../test/composer/OVaultComposer_E2E.t.sol | 5 +- .../test/composer/OVaultComposer_Unit.t.sol | 81 +++++-- 4 files changed, 87 insertions(+), 234 deletions(-) diff --git a/examples/ovault-evm/test/OVault_ERC4626_Equivalence.t.sol b/examples/ovault-evm/test/OVault_ERC4626_Equivalence.t.sol index bb4556cb2b..4c57fa734c 100644 --- a/examples/ovault-evm/test/OVault_ERC4626_Equivalence.t.sol +++ b/examples/ovault-evm/test/OVault_ERC4626_Equivalence.t.sol @@ -126,222 +126,6 @@ contract OVaultERC4626EquivalenceTest is TestHelperOz5 { assertEq(assetOFT.balanceOf(alice), alicePreDepositBal); } - function test_ovault_MultipleMintDepositRedeemWithdraw() public { - // Scenario: - // A = Alice, B = Bob - // ________________________________________________________ - // | Vault shares | A share | A assets | B share | B assets | - // |========================================================| - // | 1. Alice mints 2000 shares (costs 2000 tokens) | - // |--------------|---------|----------|---------|----------| - // | 2000 | 2000 | 2000 | 0 | 0 | - // |--------------|---------|----------|---------|----------| - // | 2. Bob deposits 4000 tokens (mints 4000 shares) | - // |--------------|---------|----------|---------|----------| - // | 6000 | 2000 | 2000 | 4000 | 4000 | - // |--------------|---------|----------|---------|----------| - // | 3. Vault mutates by +3000 tokens... | - // | (simulated yield returned from strategy)... | - // |--------------|---------|----------|---------|----------| - // | 6000 | 2000 | 3000 | 4000 | 6000 | - // |--------------|---------|----------|---------|----------| - // | 4. Alice deposits 2000 tokens (mints 1333 shares) | - // |--------------|---------|----------|---------|----------| - // | 7333 | 3333 | 4999 | 4000 | 6000 | - // |--------------|---------|----------|---------|----------| - // | 5. Bob mints 2000 shares (costs 3001 assets) | - // | NOTE: Bob's assets spent got rounded up | - // | NOTE: Alice's vault assets got rounded up | - // |--------------|---------|----------|---------|----------| - // | 9333 | 3333 | 5000 | 6000 | 9000 | - // |--------------|---------|----------|---------|----------| - // | 6. Vault mutates by +3000 tokens... | - // | (simulated yield returned from strategy) | - // | NOTE: Vault holds 17001 tokens, but sum of | - // | assetsOf() is 17000. | - // |--------------|---------|----------|---------|----------| - // | 9333 | 3333 | 6071 | 6000 | 10929 | - // |--------------|---------|----------|---------|----------| - // | 7. Alice redeem 1333 shares (2428 assets) | - // |--------------|---------|----------|---------|----------| - // | 8000 | 2000 | 3643 | 6000 | 10929 | - // |--------------|---------|----------|---------|----------| - // | 8. Bob withdraws 2928 assets (1608 shares) | - // |--------------|---------|----------|---------|----------| - // | 6392 | 2000 | 3643 | 4392 | 8000 | - // |--------------|---------|----------|---------|----------| - // | 9. Alice withdraws 3643 assets (2000 shares) | - // | NOTE: Bob's assets have been rounded back up | - // |--------------|---------|----------|---------|----------| - // | 4392 | 0 | 0 | 4392 | 8001 | - // |--------------|---------|----------|---------|----------| - // | 10. Bob redeem 4392 shares (8001 tokens) | - // |--------------|---------|----------|---------|----------| - // | 0 | 0 | 0 | 0 | 0 | - // |______________|_________|__________|_________|__________| - - address alice = address(0xABCD); - address bob = address(0xDCBA); - - uint256 mutationassetAmount = 3000; - - assetOFT.mint(alice, 4000); - - vm.prank(alice); - assetOFT.approve(address(vault), 4000); - - assertEq(assetOFT.allowance(alice, address(vault)), 4000); - - assetOFT.mint(bob, 7001); - - vm.prank(bob); - assetOFT.approve(address(vault), 7001); - - assertEq(assetOFT.allowance(bob, address(vault)), 7001); - - // 1. Alice mints 2000 shares (costs 2000 tokens) - vm.prank(alice); - uint256 aliceassetAmount = vault.mint(2000, alice); - - uint256 aliceShareAmount = vault.previewDeposit(aliceassetAmount); - - // Expect to have received the requested mint amount. - assertEq(aliceShareAmount, 2000); - assertEq(vault.balanceOf(alice), aliceShareAmount); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), aliceassetAmount); - assertEq(vault.convertToShares(aliceassetAmount), vault.balanceOf(alice)); - - // Expect a 1:1 ratio before mutation. - assertEq(aliceassetAmount, 2000); - - // Sanity check. - assertEq(vault.totalSupply(), aliceShareAmount); - assertEq(vault.totalAssets(), aliceassetAmount); - - // 2. Bob deposits 4000 tokens (mints 4000 shares) - vm.prank(bob); - uint256 bobShareAmount = vault.deposit(4000, bob); - uint256 bobassetAmount = vault.previewWithdraw(bobShareAmount); - - // Expect to have received the requested asset amount. - assertEq(bobassetAmount, 4000); - assertEq(vault.balanceOf(bob), bobShareAmount); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), bobassetAmount); - assertEq(vault.convertToShares(bobassetAmount), vault.balanceOf(bob)); - - // Expect a 1:1 ratio before mutation. - assertEq(bobShareAmount, bobassetAmount); - - // Sanity check. - uint256 preMutationShareBal = aliceShareAmount + bobShareAmount; - uint256 preMutationBal = aliceassetAmount + bobassetAmount; - assertEq(vault.totalSupply(), preMutationShareBal); - assertEq(vault.totalAssets(), preMutationBal); - assertEq(vault.totalSupply(), 6000); - assertEq(vault.totalAssets(), 6000); - - // 3. Vault mutates by +3000 tokens... | - // (simulated yield returned from strategy)... - // The Vault now contains more tokens than deposited which causes the exchange rate to change. - // Alice share is 33.33% of the Vault, Bob 66.66% of the Vault. - // Alice's share count stays the same but the asset amount changes from 2000 to 3000. - // Bob's share count stays the same but the asset amount changes from 4000 to 6000. - assetOFT.mint(address(vault), mutationassetAmount); - assertEq(vault.totalSupply(), preMutationShareBal); - assertEq(vault.totalAssets(), preMutationBal + mutationassetAmount); - assertEq(vault.balanceOf(alice), aliceShareAmount); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), aliceassetAmount + (mutationassetAmount / 3) * 1); - assertEq(vault.balanceOf(bob), bobShareAmount); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), bobassetAmount + (mutationassetAmount / 3) * 2); - - // 4. Alice deposits 2000 tokens (mints 1333 shares) - vm.prank(alice); - vault.deposit(2000, alice); - - assertEq(vault.totalSupply(), 7333); - assertEq(vault.balanceOf(alice), 3333); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 4999); - assertEq(vault.balanceOf(bob), 4000); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 6000); - - // 5. Bob mints 2000 shares (costs 3001 assets) - // NOTE: Bob's assets spent got rounded up - // NOTE: Alices's vault assets got rounded up - vm.prank(bob); - vault.mint(2000, bob); - - assertEq(vault.totalSupply(), 9333); - assertEq(vault.balanceOf(alice), 3333); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 5000); - assertEq(vault.balanceOf(bob), 6000); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 9000); - - // Sanity checks: - // Alice and bob should have spent all their tokens now - assertEq(assetOFT.balanceOf(alice), 0); - assertEq(assetOFT.balanceOf(bob), 0); - // Assets in vault: 4k (alice) + 7k (bob) + 3k (yield) + 1 (round up) - assertEq(vault.totalAssets(), 14001); - - // 6. Vault mutates by +3000 tokens - // NOTE: Vault holds 17001 tokens, but sum of assetsOf() is 17000. - assetOFT.mint(address(vault), mutationassetAmount); - assertEq(vault.totalAssets(), 17001); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 6071); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 10929); - - // 7. Alice redeem 1333 shares (2428 assets) - vm.prank(alice); - vault.redeem(1333, alice, alice); - - assertEq(assetOFT.balanceOf(alice), 2428); - assertEq(vault.totalSupply(), 8000); - assertEq(vault.totalAssets(), 14573); - assertEq(vault.balanceOf(alice), 2000); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 3643); - assertEq(vault.balanceOf(bob), 6000); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 10929); - - // 8. Bob withdraws 2929 assets (1608 shares) - vm.prank(bob); - vault.withdraw(2929, bob, bob); - - assertEq(assetOFT.balanceOf(bob), 2929); - assertEq(vault.totalSupply(), 6392); - assertEq(vault.totalAssets(), 11644); - assertEq(vault.balanceOf(alice), 2000); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 3643); - assertEq(vault.balanceOf(bob), 4392); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 8000); - - // 9. Alice withdraws 3643 assets (2000 shares) - // NOTE: Bob's assets have been rounded back up - vm.prank(alice); - vault.withdraw(3643, alice, alice); - - assertEq(assetOFT.balanceOf(alice), 6071); - assertEq(vault.totalSupply(), 4392); - assertEq(vault.totalAssets(), 8001); - assertEq(vault.balanceOf(alice), 0); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 0); - assertEq(vault.balanceOf(bob), 4392); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 8001); - - // 10. Bob redeem 4392 shares (8001 tokens) - vm.prank(bob); - vault.redeem(4392, bob, bob); - assertEq(assetOFT.balanceOf(bob), 10930); - assertEq(vault.totalSupply(), 0); - assertEq(vault.totalAssets(), 0); - assertEq(vault.balanceOf(alice), 0); - assertEq(vault.convertToAssets(vault.balanceOf(alice)), 0); - assertEq(vault.balanceOf(bob), 0); - assertEq(vault.convertToAssets(vault.balanceOf(bob)), 0); - - // Sanity check - assertEq(assetOFT.balanceOf(address(vault)), 0); - } - function test_ovault_FailDepositWithNotEnoughApproval() public { assetOFT.mint(address(this), 0.5e18); assetOFT.approve(address(vault), 0.5e18); diff --git a/examples/ovault-evm/test/composer/OVaultComposer_Base.t.sol b/examples/ovault-evm/test/composer/OVaultComposer_Base.t.sol index a9685874de..28fc9333a7 100644 --- a/examples/ovault-evm/test/composer/OVaultComposer_Base.t.sol +++ b/examples/ovault-evm/test/composer/OVaultComposer_Base.t.sol @@ -139,6 +139,25 @@ contract OVaultComposerBaseTest is TestHelperOz5 { assetOFT_arb.mint(address(oVault_arb), mintAssets); } + function _removeDustWithOffset(uint256 _amount, int128 _offset) internal pure returns (uint256, uint256) { + uint256 amountWithOffset = _amount; + if (_offset < 0) { + uint256 modOffset = uint128(-1 * _offset); + // If offset is negative, we need to ensure we don't underflow + require(_amount >= modOffset, "Offset too large"); + amountWithOffset = _amount - modOffset; + } else { + // If offset is positive, we can safely add it + amountWithOffset = _amount + uint128(_offset); + } + return _removeDust(amountWithOffset); + } + + function _removeDust(uint256 _amount) internal pure returns (uint256, uint256) { + uint256 dust = _amount % 10 ** 12; + return (_amount - dust, dust); + } + function _randomGUID() internal view returns (bytes32) { return bytes32(vm.randomBytes(32)); } diff --git a/examples/ovault-evm/test/composer/OVaultComposer_E2E.t.sol b/examples/ovault-evm/test/composer/OVaultComposer_E2E.t.sol index 27027e3e4e..46dddcd433 100644 --- a/examples/ovault-evm/test/composer/OVaultComposer_E2E.t.sol +++ b/examples/ovault-evm/test/composer/OVaultComposer_E2E.t.sol @@ -36,7 +36,7 @@ contract OVaultComposerE2ETest is OVaultComposerBaseTest { } function test_E2E_ethereum_to_polygon() public { - uint256 shareTokensToReceive = TOKENS_TO_SEND * 2; + (uint256 shareTokensToReceive, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); deal(address(assetOFT_eth), userA, TOKENS_TO_SEND); @@ -105,10 +105,11 @@ contract OVaultComposerE2ETest is OVaultComposerBaseTest { address(this), "" ); + assertEq( assetOFT_arb.balanceOf(composerAddress), 0, - "composerAddress should have no tokens after lzCompose on arb" + "composerAddress should have the no tokens after lzCompose on arb" ); verifyPackets(POL_EID, addressToBytes32(address(shareOFT_pol))); diff --git a/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol b/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol index cb86aa0f3c..67a66965b1 100644 --- a/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol +++ b/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol @@ -239,7 +239,7 @@ 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.CanRetryWithSwap)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwapOrRefund)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); @@ -304,12 +304,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); - ( - address oft, - SendParam memory sendParam, - address refundOFT, - SendParam memory refundSendParam - ) = OVaultComposerArb.failedMessages(guid); + (address oft, SendParam memory sendParam, address refundOFT, ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(0), "refundOFT should be 0 - not possible"); assertEq(oft, address(shareOFT_arb), "retry oft should be shareOFT_arb"); @@ -318,19 +313,64 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(sendParam.amountLD, TOKENS_TO_SEND, "retry amountLD should be TOKENS_TO_SEND"); assertEq(sendParam.minAmountLD, 0, "retry minAmountLD should be 0"); assertEq(sendParam.extraOptions, OPTIONS_LZRECEIVE_2M, "retry extraOptions should be OPTIONS_LZRECEIVE_2M"); - - assertEq(refundSendParam.dstEid, ETH_EID, "refund dstEid should be ETH_EID"); - assertEq(refundSendParam.to, addressToBytes32(userA), "refund to should be userA"); - assertEq(refundSendParam.amountLD, TOKENS_TO_SEND, "refund amountLD should be TOKENS_TO_SEND"); - assertEq(refundSendParam.minAmountLD, 0, "refund minAmountLD should be 0"); - assertEq(refundSendParam.extraOptions, bytes(""), "refund extraOptions should be empty"); } function test_lzCompose_slippage_retry_with_swap() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); - uint256 targetAmount = TOKENS_TO_SEND * 2; + (uint256 targetAmount, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + targetAmount, + OPTIONS_LZRECEIVE_2M, + "", + "" + ); + + bytes memory composePayload = abi.encode(internalSendParam); + bytes memory composeMsg = _createComposePayload(ETH_EID, composePayload, TOKENS_TO_SEND, userA); + + vm.expectEmit(true, true, true, true, address(OVaultComposerArb)); + bytes memory errMsg = abi.encodeWithSelector( + IOVaultComposer.NotEnoughTargetTokens.selector, + TOKENS_TO_SEND, + targetAmount + ); + emit IOVaultComposer.OVaultError(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.CanRetryWithSwapOrRefund)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); + + OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), mintAssets + TOKENS_TO_SEND); + + (uint256 _ovaultTotalSupply, ) = _removeDust(oVault_arb.totalSupply()); + assertEq( + _ovaultTotalSupply, + oVault_arb.balanceOf(address(0xbeef)) + oVault_arb.balanceOf(address(shareOFT_arb)), + mintShares + targetAmount + ); + } + + function test_lzCompose_slippage_retry_with_swap_failed_retains_transaction() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + (uint256 targetAmount, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); SendParam memory internalSendParam = SendParam( POL_EID, @@ -356,7 +396,14 @@ 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.CanRetryWithSwap)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwapOrRefund)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.expectRevert(); + OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwapOrRefund)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); @@ -369,8 +416,10 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), mintAssets + TOKENS_TO_SEND); + (uint256 _ovaultTotalSupply, ) = _removeDust(oVault_arb.totalSupply()); + assertEq( - oVault_arb.totalSupply(), + _ovaultTotalSupply, oVault_arb.balanceOf(address(0xbeef)) + oVault_arb.balanceOf(address(shareOFT_arb)), mintShares + targetAmount ); From 2305ecd1b48cc3f427b2b60f30477ea3f529d0d2 Mon Sep 17 00:00:00 2001 From: shankar Date: Fri, 4 Jul 2025 15:46:18 +0000 Subject: [PATCH 17/30] fix: refund Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index dd6c4031f4..af1ee5f0dc 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -161,15 +161,17 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } /// @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 + /// @dev Always possible unless the lzCompose() fails due to an Out-Of-Gas panic or we are in retry-only mode function refund(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { FailedMessage memory failedMessage = failedMessages[_guid]; - if (failedMessage.refundOFT != address(0)) revert CanNotRefund(_guid); + + FailedState f = _failedGuidState(failedMessage); + if ((f != FailedState.CanOnlyRefund) && (f != FailedState.CanRetryWithSwapOrRefund)) revert CanNotRefund(_guid); delete failedMessages[_guid]; SendParam memory refundSendParam = failedMessage.refundSendParam; - address refundOft = failedMessage.oft; + address refundOft = failedMessage.refundOFT; /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver if (refundSendParam.dstEid == HUB_EID) { From 2ef95ce12c059318dc3d205613139634db85e989 Mon Sep 17 00:00:00 2001 From: shankar Date: Fri, 4 Jul 2025 15:46:34 +0000 Subject: [PATCH 18/30] performing refund testing Signed-off-by: shankar --- .../test/composer/OVaultComposer_Unit.t.sol | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol index 07e6b643d9..4938a36517 100644 --- a/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol +++ b/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol @@ -121,9 +121,10 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(userA)), TOKENS_TO_SEND); } - function test_lzCompose_fail_invalid_payload() public { + function test_lzCompose_fail_invalid_payload_and_can_refund() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + uint256 userBBalanceEth = assetOFT_arb.balanceOf(userB); bytes memory invalidPayload = bytes("0x1234"); @@ -153,12 +154,19 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(refundSendParam.extraOptions, "", "refund extraOptions should be empty"); assertEmpty(sendParam); + + OVaultComposerArb.refund{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + + verifyPackets(ETH_EID, address(assetOFT_arb)); + assertEq(assetOFT_arb.balanceOf(userA), userBBalanceEth, "userA should have the same asset amount on Ethereum"); } - function test_lzCompose_quoteSend_fail() public { + function test_lzCompose_quoteSend_fail_and_can_refund() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + uint256 userBBalanceEth = assetOFT_arb.balanceOf(userB); + SendParam memory internalSendParam = SendParam( BAD_EID, addressToBytes32(userB), @@ -206,9 +214,14 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { expectedSendParam.amountLD = 0; assertEq(sendParam, expectedSendParam); + + OVaultComposerArb.refund{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + + verifyPackets(ETH_EID, address(assetOFT_arb)); + assertEq(assetOFT_arb.balanceOf(userA), userBBalanceEth, "userA should have the same asset amount on Ethereum"); } - function test_lzCompose_slippage_on_target_token() public { + function test_lzCompose_slippage_on_target_token_and_can_retry_with_swap_or_refund() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); @@ -266,10 +279,12 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(sendParam, expectedSendParam); } - function test_lzCompose_fail_insufficient_fee_amount() public { + function test_lzCompose_fail_insufficient_fee_amount_and_can_retry() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); + SendParam memory internalSendParam = SendParam( POL_EID, addressToBytes32(userB), @@ -313,6 +328,19 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { 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"); + + verifyPackets(POL_EID, address(shareOFT_pol)); + assertEq(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have the same shares on Polygon"); + + OVaultComposerArb.retry{ 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)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(shareOFT_arb)), TOKENS_TO_SEND); + + verifyPackets(POL_EID, address(shareOFT_pol)); + assertGt(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have more shares on Polygon"); } function test_lzCompose_slippage_retry_with_swap() public { @@ -321,6 +349,8 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { (uint256 targetAmount, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); + uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); + SendParam memory internalSendParam = SendParam( POL_EID, addressToBytes32(userB), @@ -350,8 +380,10 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); - (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); + verifyPackets(POL_EID, address(shareOFT_pol)); + assertEq(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have the same shares on Polygon"); + (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); @@ -364,6 +396,9 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { oVault_arb.balanceOf(address(0xbeef)) + oVault_arb.balanceOf(address(shareOFT_arb)), mintShares + targetAmount ); + + verifyPackets(POL_EID, address(shareOFT_pol)); + assertGt(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have more shares on Polygon"); } function test_lzCompose_slippage_retry_with_swap_failed_retains_transaction() public { From a93eb556bc3506fe57690b1def176917438df41f Mon Sep 17 00:00:00 2001 From: shankar Date: Fri, 4 Jul 2025 15:55:05 +0000 Subject: [PATCH 19/30] event emission on retry, refund, retryWithSwap Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 16 +++++++--------- .../contracts/interfaces/IOVaultComposer.sol | 1 + 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index af1ee5f0dc..9291ac8a8b 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -176,12 +176,11 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver if (refundSendParam.dstEid == HUB_EID) { _executeHubTransfer(refundOft, refundSendParam.to.bytes32ToAddress(), refundSendParam.amountLD); - return; + } else { + refundSendParam.extraOptions = _extraOptions; + _send(refundOft, refundSendParam); } - refundSendParam.extraOptions = _extraOptions; - - _send(refundOft, refundSendParam); emit Refunded(_guid, refundOft); } @@ -199,12 +198,11 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver if (sendParam.dstEid == HUB_EID) { _executeHubTransfer(retryOFT, sendParam.to.bytes32ToAddress(), sendParam.amountLD); - return; + } else { + sendParam.extraOptions = _extraOptions; + _send(retryOFT, sendParam); } - sendParam.extraOptions = _extraOptions; - - _send(retryOFT, sendParam); emit Retried(_guid, retryOFT); } @@ -237,7 +235,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { sendParam.extraOptions = _extraOptions; _send(retryOFT, sendParam); - emit Sent(_guid, retryOFT); + emit RetriedWithSwap(_guid, retryOFT); } /// @dev Helper to view the state of a failed message diff --git a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol index b6aadb26fc..d1c1474fd1 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -26,6 +26,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 RetriedWithSwap(bytes32 indexed guid, address indexed oft); event OVaultError(bytes32 indexed guid, address indexed oft, bytes errMsg); event NoPeer(bytes32 indexed guid, address indexed oft, uint32 dstEid); From f109d4acd75f86fc86ad93e189acef9b3919362f Mon Sep 17 00:00:00 2001 From: shankar Date: Fri, 4 Jul 2025 16:19:15 +0000 Subject: [PATCH 20/30] update to match packages Signed-off-by: shankar --- .../test/composer/OVaultComposer_Unit.t.sol | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol b/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol index 67a66965b1..6653ba1643 100644 --- a/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol +++ b/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol @@ -14,7 +14,6 @@ import { OVaultComposer } from "@layerzerolabs/ovault-evm/contracts/OVaultCompos import { OVaultComposerBaseTest } from "./OVaultComposer_Base.t.sol"; import { console } from "forge-std/console.sol"; - contract OVaultComposerUnitTest is OVaultComposerBaseTest { using OptionsBuilder for bytes; @@ -121,9 +120,10 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(userA)), TOKENS_TO_SEND); } - function test_lzCompose_fail_invalid_payload() public { + function test_lzCompose_fail_invalid_payload_and_can_refund() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + uint256 userBBalanceEth = assetOFT_arb.balanceOf(userB); bytes memory invalidPayload = bytes("0x1234"); @@ -153,12 +153,19 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(refundSendParam.extraOptions, "", "refund extraOptions should be empty"); assertEmpty(sendParam); + + OVaultComposerArb.refund{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + + verifyPackets(ETH_EID, address(assetOFT_arb)); + assertEq(assetOFT_arb.balanceOf(userA), userBBalanceEth, "userA should have the same asset amount on Ethereum"); } - function test_lzCompose_quoteSend_fail() public { + function test_lzCompose_quoteSend_fail_and_can_refund() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + uint256 userBBalanceEth = assetOFT_arb.balanceOf(userB); + SendParam memory internalSendParam = SendParam( BAD_EID, addressToBytes32(userB), @@ -206,9 +213,14 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { expectedSendParam.amountLD = 0; assertEq(sendParam, expectedSendParam); + + OVaultComposerArb.refund{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + + verifyPackets(ETH_EID, address(assetOFT_arb)); + assertEq(assetOFT_arb.balanceOf(userA), userBBalanceEth, "userA should have the same asset amount on Ethereum"); } - function test_lzCompose_slippage_on_target_token() public { + function test_lzCompose_slippage_on_target_token_and_can_retry_with_swap_or_refund() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); @@ -266,10 +278,12 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(sendParam, expectedSendParam); } - function test_lzCompose_fail_insufficient_fee_amount() public { + function test_lzCompose_fail_insufficient_fee_amount_and_can_retry() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); + SendParam memory internalSendParam = SendParam( POL_EID, addressToBytes32(userB), @@ -313,6 +327,19 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { 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"); + + verifyPackets(POL_EID, address(shareOFT_pol)); + assertEq(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have the same shares on Polygon"); + + OVaultComposerArb.retry{ 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)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(shareOFT_arb)), TOKENS_TO_SEND); + + verifyPackets(POL_EID, address(shareOFT_pol)); + assertGt(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have more shares on Polygon"); } function test_lzCompose_slippage_retry_with_swap() public { @@ -321,6 +348,8 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { (uint256 targetAmount, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); + uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); + SendParam memory internalSendParam = SendParam( POL_EID, addressToBytes32(userB), @@ -350,8 +379,10 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); - (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); + verifyPackets(POL_EID, address(shareOFT_pol)); + assertEq(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have the same shares on Polygon"); + (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); @@ -364,6 +395,9 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { oVault_arb.balanceOf(address(0xbeef)) + oVault_arb.balanceOf(address(shareOFT_arb)), mintShares + targetAmount ); + + verifyPackets(POL_EID, address(shareOFT_pol)); + assertGt(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have more shares on Polygon"); } function test_lzCompose_slippage_retry_with_swap_failed_retains_transaction() public { From 7c7006503a9fac83307ca94b6f49b156e19c3c84 Mon Sep 17 00:00:00 2001 From: shankar Date: Fri, 4 Jul 2025 21:26:18 +0000 Subject: [PATCH 21/30] enforcedOptions in retry, refund, and retryWithSwap #19 + cleanup and grouping similar functions Signed-off-by: shankar --- .../ovault-evm/contracts/OVaultComposer.sol | 202 +++++++++++------- .../contracts/interfaces/IOVaultComposer.sol | 28 ++- .../test/composer/OVaultComposer_Base.t.sol | 19 +- .../test/composer/OVaultComposer_Unit.t.sol | 77 +++---- 4 files changed, 188 insertions(+), 138 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 9291ac8a8b..3e94910a7f 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -25,9 +25,11 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { address public immutable ENDPOINT; uint32 public immutable HUB_EID; + address public immutable REFUND_OVERPAY_ADDRESS; + mapping(bytes32 guid => FailedMessage) public failedMessages; - constructor(address _ovault, address _assetOFT, address _shareOFT) { + constructor(address _ovault, address _assetOFT, address _shareOFT, address _refundOverpayAddress) { OVAULT = IERC4626(_ovault); ASSET_OFT = _assetOFT; SHARE_OFT = _shareOFT; @@ -53,6 +55,8 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { // Approve the shareOFTAdapter with the share tokens held by this contract IERC20(IOFT(_shareOFT).token()).approve(_shareOFT, type(uint256).max); + + REFUND_OVERPAY_ADDRESS = _refundOverpayAddress; } function lzCompose( @@ -88,39 +92,42 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } 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); + failedMessages[_guid] = FailedMessage(address(0), sendParam, _refundOFT, refundSendParam, msg.value); emit DecodeFailed(_guid, _refundOFT, sendParamEncoded); return; } /// @dev Try to early catch ONLY when the target OFT does not have a peer set for the destination chain. + /// @dev This is because we do not know if the vault protocol wants to expand to that chain. if (_isInvalidPeer(oft, sendParam.dstEid)) { - failedMessages[_guid] = FailedMessage(address(0), sendParam, _refundOFT, refundSendParam); + failedMessages[_guid] = FailedMessage(address(0), sendParam, _refundOFT, refundSendParam, msg.value); emit NoPeer(_guid, oft, sendParam.dstEid); return; } - /// @dev Try to execute the action on the target OFT. If we hit an issue then it rolls back the storage changes. + /// @dev Try to execute the action on the target OFT. If we hit an error then it rolls back the storage changes. try this.executeOVaultActionWithSlippageCheck(_refundOFT, amount, sendParam.minAmountLD) returns ( uint256 vaultAmount ) { /// @dev Setting the target amount to the actual value of the action (i.e. deposit or redeem) sendParam.amountLD = vaultAmount; } catch (bytes memory errMsg) { - failedMessages[_guid] = FailedMessage(oft, sendParam, _refundOFT, refundSendParam); + failedMessages[_guid] = FailedMessage(oft, sendParam, _refundOFT, refundSendParam, msg.value); emit OVaultError(_guid, oft, errMsg); /// @dev Since the ovault can revert with custom errors, the error message is valuable return; } - /// @dev Try sending the message to the target OFT + /// @dev Try sending the vault out tokens to the receiver on the target chain (can also be the HUB chain) try this.send{ value: msg.value }(oft, sendParam) { emit Sent(_guid, oft); } catch { SendParam memory emptySendParam; - /// @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), emptySendParam); - emit SendFailed(_guid, oft); /// @dev This can be due to msg.value or layerzero config (dvn config, etc) + /// @dev A failed send will result in the target tokens being in the composer, we can retry with: + /// @dev 1. more msg.value OR + /// @dev 2. after the config is fixed OR + /// @dev 3. without extraOptions (re-executor needs to pay the entire msg.value) + failedMessages[_guid] = FailedMessage(oft, sendParam, address(0), emptySendParam, msg.value); + emit SendFailed(_guid, oft); return; } } @@ -149,93 +156,110 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @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); - - /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver - if (_sendParam.dstEid == HUB_EID) { - _executeHubTransfer(_oft, _sendParam.to.bytes32ToAddress(), _sendParam.amountLD); - return; - } - - /// @dev If the destination is not the HUB chain, we send the message to the target OFT - _send(_oft, _sendParam); + _send(_oft, _sendParam, msg.value, tx.origin); } - /// @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 or we are in retry-only mode - function refund(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { + /// @dev Always uses enforced options to send the transaction to an EOA. + /// @dev Custom logic to be implemented by the developer in the case where the sender is NOT an EOA. + /// @dev If the total msg.value (cached + supplier) is greater than the consumed msg.value, the excess is sent to the REFUND_OVERPAY_ADDRESS + function refund(bytes32 _guid) external payable nonReentrant { FailedMessage memory failedMessage = failedMessages[_guid]; FailedState f = _failedGuidState(failedMessage); - if ((f != FailedState.CanOnlyRefund) && (f != FailedState.CanRetryWithSwapOrRefund)) revert CanNotRefund(_guid); - - delete failedMessages[_guid]; - - SendParam memory refundSendParam = failedMessage.refundSendParam; - address refundOft = failedMessage.refundOFT; - - /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver - if (refundSendParam.dstEid == HUB_EID) { - _executeHubTransfer(refundOft, refundSendParam.to.bytes32ToAddress(), refundSendParam.amountLD); - } else { - refundSendParam.extraOptions = _extraOptions; - _send(refundOft, refundSendParam); + if ((f != FailedState.CanOnlyRefund) && (f != FailedState.CanRefundOrRetryWithSwap)) revert CanNotRefund(_guid); + + try + this.sendFailedMessage{ value: msg.value }( + _guid, + failedMessage.refundOFT, + failedMessage.refundSendParam, + failedMessage.msgValue, + REFUND_OVERPAY_ADDRESS + ) + { + emit Refunded(_guid, failedMessage.refundOFT); + } catch { + emit SendFailed(_guid, failedMessage.refundOFT); } - - emit Refunded(_guid, refundOft); } - /// @dev Permissionless function to retry the message with more gas - /// @dev Failure case when there is a LayerZero config issue when a peer is set - ex: dvn config - function retry(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { + /// @dev If removeExtraOptions is true, only enforcedOptions are used and the sender needs to pay the entire msg.value + /// @dev A transaction can never fail due to a lack of extraOptions, unless the OFT is customized in-which case the Composer would need to be customized. + function retry(bytes32 _guid, bool removeExtraOptions) external payable nonReentrant { FailedMessage memory failedMessage = failedMessages[_guid]; if (_failedGuidState(failedMessage) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); - delete failedMessages[_guid]; - SendParam memory sendParam = failedMessage.sendParam; - address retryOFT = failedMessage.oft; + uint256 prePaidMsgValue = failedMessage.msgValue; - /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver - if (sendParam.dstEid == HUB_EID) { - _executeHubTransfer(retryOFT, sendParam.to.bytes32ToAddress(), sendParam.amountLD); - } else { - sendParam.extraOptions = _extraOptions; - _send(retryOFT, sendParam); + if (removeExtraOptions) { + sendParam.extraOptions = ""; + prePaidMsgValue = 0; + + payable(REFUND_OVERPAY_ADDRESS).transfer(prePaidMsgValue); } - emit Retried(_guid, retryOFT); + try + this.sendFailedMessage{ value: msg.value }(_guid, failedMessage.oft, sendParam, prePaidMsgValue, tx.origin) + { + emit Retried(_guid, failedMessage.oft); + } catch { + emit SendFailed(_guid, failedMessage.oft); + } } - /// @dev Retry mechanism for transactions that failed due to slippage. This can revert. - /// @dev Failure case when there is a LayerZero config issue when a peer is set - ex: dvn config - function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable nonReentrant { + /// @dev Performs the ovault action for a failed swap. If we fail on the send then the GUID enters the retry state for manual execution. + function retryWithSwap(bytes32 _guid, bool skipRetry) external payable nonReentrant { FailedMessage memory failedMessage = failedMessages[_guid]; - if (_failedGuidState(failedMessage) != FailedState.CanRetryWithSwapOrRefund) revert CanNotRetry(_guid); + if (_failedGuidState(failedMessage) != FailedState.CanRefundOrRetryWithSwap) revert CanNotSwap(_guid); - uint256 srcAmount = failedMessage.refundSendParam.amountLD; - delete failedMessages[_guid]; + /// @dev Disable refund. If this call reverts refund is still possible. + failedMessages[_guid].refundOFT = address(0); - SendParam memory sendParam = failedMessage.sendParam; - address retryOFT = failedMessage.oft; + uint256 srcAmount = failedMessage.refundSendParam.amountLD; + uint256 minAmountLD = failedMessage.sendParam.minAmountLD; uint256 vaultAmount = _executeOVaultAction(failedMessage.refundOFT, srcAmount); - if (vaultAmount < sendParam.minAmountLD) { - revert NotEnoughTargetTokens(vaultAmount, sendParam.minAmountLD); + if (vaultAmount < minAmountLD) { + /// @dev Will rollback on this function's storage changes (trade does not happen) + revert NotEnoughTargetTokens(vaultAmount, minAmountLD); } - sendParam.amountLD = vaultAmount; - - /// @dev If the destination is the HUB chain, we just transfer the tokens to the receiver - if (sendParam.dstEid == HUB_EID) { - _executeHubTransfer(retryOFT, sendParam.to.bytes32ToAddress(), sendParam.amountLD); - return; + failedMessage.sendParam.amountLD = vaultAmount; + emit SwappedTokens(_guid); + + /// @dev Regardless of the outcome of skipRetry and sendFailedMessage, the failedMessage is now in a RETRY only state. + if (!skipRetry) { + try + this.sendFailedMessage{ value: msg.value }( + _guid, + failedMessage.oft, + failedMessage.sendParam, + failedMessage.msgValue, + tx.origin + ) + { + emit Retried(_guid, failedMessage.oft); + } catch { + emit SendFailed(_guid, failedMessage.oft); + } } + } + + function sendFailedMessage( + bytes32 _guid, + address _oft, + SendParam memory _sendParam, + uint256 _prePaidMsgValue, + address _refundOverpayAddress + ) external payable { + if (msg.sender != address(this)) revert OnlySelf(msg.sender); - sendParam.extraOptions = _extraOptions; + delete failedMessages[_guid]; - _send(retryOFT, sendParam); - emit RetriedWithSwap(_guid, retryOFT); + uint256 totalMsgValue = _prePaidMsgValue + msg.value; + _send(_oft, _sendParam, totalMsgValue, _refundOverpayAddress); } /// @dev Helper to view the state of a failed message @@ -253,20 +277,35 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } /// @dev Internal function to send the message to the target OFT - /// @dev In the event you're using a bundler or anything where the tx.origin is not the right receiver then this function will have to be overridden. + /// @dev In the event you're using a bundler or anything where the tx.origin is not the intended refund receiver then this function will have to be overridden. /// @dev Slippage check happens at the OFT for : amountLD >= sendParam.minAmountLD - function _send(address _oft, SendParam memory _sendParam) internal { - IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), tx.origin); - } + /// @dev Handles the case where the dstEid is the HUB chain + function _send( + address _oft, + SendParam memory _sendParam, + uint256 _totalMsgValue, + address _refundOverpayAddress + ) internal { + if (_sendParam.dstEid == HUB_EID) { + address token = IOFT(_oft).token(); + address to = _sendParam.to.bytes32ToAddress(); + + uint256 amountLD = _sendParam.amountLD; + IERC20(token).safeTransfer(to, amountLD); + + if (_totalMsgValue > 0) { + (bool sent, ) = to.call{ value: _totalMsgValue }(""); + require(sent, "Failed to send Ether"); + } - function _executeHubTransfer(address _oft, address _receiver, uint256 _amountLD) internal { - IERC20 token = IERC20(IOFT(_oft).token()); - token.safeTransfer(_receiver, _amountLD); - if (msg.value > 0) { - (bool sent, ) = _receiver.call{ value: msg.value }(""); - require(sent, "Failed to send Ether"); + emit SentOnHub(to, _oft, amountLD); + } else { + IOFT(_oft).send{ value: _totalMsgValue }( + _sendParam, + MessagingFee(_totalMsgValue, 0), + _refundOverpayAddress + ); } - emit SentOnHub(_receiver, _oft, _amountLD); } /// @dev Helper to check if the target OFT does not have a peer set for the destination chain OR if our target chain is not the same as the HUB chain @@ -285,7 +324,8 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { return FailedState.CanOnlyRetry; } - return FailedState.CanRetryWithSwapOrRefund; + return FailedState.CanRefundOrRetryWithSwap; } + receive() external payable {} } diff --git a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol index d1c1474fd1..5bfea41025 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -9,24 +9,28 @@ struct FailedMessage { SendParam sendParam; address refundOFT; SendParam refundSendParam; + uint256 msgValue; } enum FailedState { NotFound, CanOnlyRefund, CanOnlyRetry, - CanRetryWithSwapOrRefund + CanRefundOrRetryWithSwap } interface IOVaultComposer is IOAppComposer { /// ========================== EVENTS ===================================== event DecodeFailed(bytes32 indexed guid, address indexed oft, bytes message); + event Sent(bytes32 indexed guid, address indexed oft); event SentOnHub(address indexed receiver, address indexed oft, uint256 amountLD); - event SendFailed(bytes32 indexed guid, address indexed oft); + event Refunded(bytes32 indexed guid, address indexed oft); event Retried(bytes32 indexed guid, address indexed oft); - event RetriedWithSwap(bytes32 indexed guid, address indexed oft); + event SwappedTokens(bytes32 indexed guid); + + event SendFailed(bytes32 indexed guid, address indexed oft); event OVaultError(bytes32 indexed guid, address indexed oft, bytes errMsg); event NoPeer(bytes32 indexed guid, address indexed oft, uint32 dstEid); @@ -40,20 +44,32 @@ interface IOVaultComposer is IOAppComposer { error OnlyOFT(address oft); error OnlyAsset(address asset); error OnlyShare(address share); + error CanNotRefund(bytes32 guid); error CanNotRetry(bytes32 guid); + error CanNotSwap(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); + function REFUND_OVERPAY_ADDRESS() external view returns (address); /// ========================== FUNCTIONS ===================================== - 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 refund(bytes32 guid) external payable; + function retry(bytes32 guid, bool removeExtraOptions) external payable; + function retryWithSwap(bytes32 guid, bool skipRetry) external payable; + + function sendFailedMessage( + bytes32 _guid, + address _oft, + SendParam memory _sendParam, + uint256 _prePaidMsgValue, + address _refundOverpayAddress + ) external payable; function failedGuidState(bytes32 guid) external view returns (FailedState); diff --git a/packages/ovault-evm/test/composer/OVaultComposer_Base.t.sol b/packages/ovault-evm/test/composer/OVaultComposer_Base.t.sol index e1840ab0e8..420b040d56 100644 --- a/packages/ovault-evm/test/composer/OVaultComposer_Base.t.sol +++ b/packages/ovault-evm/test/composer/OVaultComposer_Base.t.sol @@ -7,6 +7,7 @@ import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/Opti // OFT imports import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; import { SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; +import { EnforcedOptionParam } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppOptionsType3.sol"; import { OVaultComposer } from "../../contracts/OVaultComposer.sol"; @@ -45,9 +46,11 @@ contract OVaultComposerBaseTest is TestHelperOz5 { address public userA = makeAddr("userA"); address public userB = makeAddr("userB"); + address public refundOverpayAddress = makeAddr("refundOverpayAddress"); + address public arbEndpoint; address public arbExecutor = makeAddr("arbExecutor"); - bytes public OPTIONS_LZRECEIVE_2M = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0); + bytes public OPTIONS_LZRECEIVE_100k = OptionsBuilder.newOptions().addExecutorLzReceiveOption(100_000, 0); uint256 public constant INITIAL_BALANCE = 100 ether; uint256 public constant TOKENS_TO_SEND = 1 ether; @@ -73,7 +76,12 @@ contract OVaultComposerBaseTest is TestHelperOz5 { /// Now the "expansion" is for the arb vault and share ofts on other networks. oVault_arb = new MockOVault("arbShare", "arbShare", address(assetOFT_arb)); shareOFT_arb = new MockOFTAdapter(address(oVault_arb), address(endpoints[ARB_EID]), address(this)); - OVaultComposerArb = new OVaultComposer(address(oVault_arb), address(assetOFT_arb), address(shareOFT_arb)); + OVaultComposerArb = new OVaultComposer( + address(oVault_arb), + address(assetOFT_arb), + address(shareOFT_arb), + refundOverpayAddress + ); /// Deploy the Share OFTs on other networks - these are NOT lockbox adapters. shareOFT_eth = new MockOFT("ethShare", "ethShare", address(endpoints[ETH_EID]), address(this)); @@ -98,6 +106,13 @@ contract OVaultComposerBaseTest is TestHelperOz5 { deal(arbExecutor, INITIAL_BALANCE); deal(arbEndpoint, INITIAL_BALANCE); + + EnforcedOptionParam[] memory enforcedOptions = new EnforcedOptionParam[](2); + enforcedOptions[0] = EnforcedOptionParam({ eid: ETH_EID, msgType: 1, options: OPTIONS_LZRECEIVE_100k }); + enforcedOptions[1] = EnforcedOptionParam({ eid: POL_EID, msgType: 1, options: OPTIONS_LZRECEIVE_100k }); + + assetOFT_arb.setEnforcedOptions(enforcedOptions); + shareOFT_arb.setEnforcedOptions(enforcedOptions); } function _createComposePayload( diff --git a/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol b/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol index 4938a36517..957fbe5a38 100644 --- a/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol +++ b/packages/ovault-evm/test/composer/OVaultComposer_Unit.t.sol @@ -41,19 +41,11 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { OVaultComposerArb.lzCompose{ value: 1 ether }(_oft, _randomGUID(), "", arbExecutor, ""); } - function test_lzCompose_pass() public { + function test_lzCompose_pass_dst_not_hub() 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, - "", - "" - ); + SendParam memory internalSendParam = SendParam(POL_EID, addressToBytes32(userA), TOKENS_TO_SEND, 0, "", "", ""); bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); @@ -81,7 +73,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(shareOFT_arb)), TOKENS_TO_SEND); } - function test_lzCompose_pass_on_hub() public { + function test_lzCompose_pass_dst_is_hub() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); @@ -90,7 +82,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userA), TOKENS_TO_SEND, 0, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -142,7 +134,8 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { address oft, SendParam memory sendParam, address refundOFT, - SendParam memory refundSendParam + SendParam memory refundSendParam, + ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); @@ -155,7 +148,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEmpty(sendParam); - OVaultComposerArb.refund{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.refund(guid); verifyPackets(ETH_EID, address(assetOFT_arb)); assertEq(assetOFT_arb.balanceOf(userA), userBBalanceEth, "userA should have the same asset amount on Ethereum"); @@ -167,15 +160,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { uint256 userBBalanceEth = assetOFT_arb.balanceOf(userB); - SendParam memory internalSendParam = SendParam( - BAD_EID, - addressToBytes32(userB), - TOKENS_TO_SEND, - 0, - OPTIONS_LZRECEIVE_2M, - "", - "" - ); + SendParam memory internalSendParam = SendParam(BAD_EID, addressToBytes32(userB), TOKENS_TO_SEND, 0, "", "", ""); bytes memory composePayload = abi.encode(internalSendParam); bytes memory composeMsg = _createComposePayload(ETH_EID, composePayload, TOKENS_TO_SEND, userA); @@ -198,7 +183,8 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { address oft, SendParam memory sendParam, address refundOFT, - SendParam memory refundSendParam + SendParam memory refundSendParam, + ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); @@ -215,7 +201,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(sendParam, expectedSendParam); - OVaultComposerArb.refund{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.refund(guid); verifyPackets(ETH_EID, address(assetOFT_arb)); assertEq(assetOFT_arb.balanceOf(userA), userBBalanceEth, "userA should have the same asset amount on Ethereum"); @@ -230,7 +216,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userB), TOKENS_TO_SEND, TOKENS_TO_SEND + 1, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -252,7 +238,7 @@ 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.CanRetryWithSwapOrRefund)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRefundOrRetryWithSwap)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); @@ -261,7 +247,8 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { address oft, SendParam memory sendParam, address refundOFT, - SendParam memory refundSendParam + SendParam memory refundSendParam, + ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); @@ -285,15 +272,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); - SendParam memory internalSendParam = SendParam( - POL_EID, - addressToBytes32(userB), - TOKENS_TO_SEND, - 0, - OPTIONS_LZRECEIVE_2M, - "", - "" - ); + SendParam memory internalSendParam = SendParam(POL_EID, addressToBytes32(userB), TOKENS_TO_SEND, 0, "", "", ""); bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); @@ -319,7 +298,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); - (address oft, SendParam memory sendParam, address refundOFT, ) = OVaultComposerArb.failedMessages(guid); + (address oft, SendParam memory sendParam, address refundOFT, , ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(0), "refundOFT should be 0 - not possible"); assertEq(oft, address(shareOFT_arb), "retry oft should be shareOFT_arb"); @@ -327,12 +306,12 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { 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(sendParam.extraOptions, "", "retry extraOptions should be empty"); verifyPackets(POL_EID, address(shareOFT_pol)); assertEq(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have the same shares on Polygon"); - OVaultComposerArb.retry{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.retry{ value: 1 ether }(guid, false); assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); @@ -343,7 +322,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertGt(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have more shares on Polygon"); } - function test_lzCompose_slippage_retry_with_swap() public { + function test_lzCompose_slippage_retry_with_swap_works() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); @@ -356,7 +335,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userB), TOKENS_TO_SEND, targetAmount, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -375,7 +354,7 @@ 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.CanRetryWithSwapOrRefund)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRefundOrRetryWithSwap)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); @@ -384,7 +363,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have the same shares on Polygon"); (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); - OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.retryWithSwap(guid, false); assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); @@ -412,7 +391,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userB), TOKENS_TO_SEND, targetAmount, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -431,21 +410,21 @@ 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.CanRetryWithSwapOrRefund)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRefundOrRetryWithSwap)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); vm.expectRevert(); - OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); - assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwapOrRefund)); + OVaultComposerArb.retryWithSwap(guid, false); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRefundOrRetryWithSwap)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); - OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.retryWithSwap(guid, false); assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); From d6141e5f11c58f485281635f01634daef154a449 Mon Sep 17 00:00:00 2001 From: shankar Date: Fri, 4 Jul 2025 21:26:31 +0000 Subject: [PATCH 22/30] update to match package update Signed-off-by: shankar --- .../ovault-evm/contracts/MyOVaultComposer.sol | 7 +- examples/ovault-evm/deploy/MyOVault.ts | 8 +- .../test/composer/OVaultComposer_Base.t.sol | 19 ++++- .../test/composer/OVaultComposer_Unit.t.sol | 78 +++++++------------ 4 files changed, 59 insertions(+), 53 deletions(-) diff --git a/examples/ovault-evm/contracts/MyOVaultComposer.sol b/examples/ovault-evm/contracts/MyOVaultComposer.sol index e4691dbe82..304e10d769 100644 --- a/examples/ovault-evm/contracts/MyOVaultComposer.sol +++ b/examples/ovault-evm/contracts/MyOVaultComposer.sol @@ -4,5 +4,10 @@ pragma solidity ^0.8.22; import { OVaultComposer } from "@layerzerolabs/ovault-evm/contracts/OVaultComposer.sol"; contract MyOVaultComposer is OVaultComposer { - constructor(address _ovault, address _assetOFT, address _shareOFT) OVaultComposer(_ovault, _assetOFT, _shareOFT) {} + constructor( + address _ovault, + address _assetOFT, + address _shareOFT, + address _refundOverpayAddress + ) OVaultComposer(_ovault, _assetOFT, _shareOFT, _refundOverpayAddress) {} } diff --git a/examples/ovault-evm/deploy/MyOVault.ts b/examples/ovault-evm/deploy/MyOVault.ts index af412a0840..047d07b172 100644 --- a/examples/ovault-evm/deploy/MyOVault.ts +++ b/examples/ovault-evm/deploy/MyOVault.ts @@ -11,6 +11,8 @@ const tokenSymbol = 'MockShare' const shareOFTAdapterContractName = 'MyShareOFTAdapter' const composerContractName = 'MyOVaultComposer' +const refundOverpayAddress = '0x0000000000000000000000000000000000000000' + const deploy: DeployFunction = async (hre) => { const { getNamedAccounts, deployments } = hre @@ -24,6 +26,10 @@ const deploy: DeployFunction = async (hre) => { const assetOFTDeployment = await hre.deployments.get(assetOFTContractName) + if (refundOverpayAddress === '0x0000000000000000000000000000000000000000') { + throw new Error('Refund overpay address is not set') + } + const { address: ovaultAddress } = await deploy(ovaultContractName, { from: deployer, args: [tokenName, tokenSymbol, assetOFTDeployment.address], @@ -48,7 +54,7 @@ const deploy: DeployFunction = async (hre) => { const { address: composerAddress } = await deploy(composerContractName, { from: deployer, - args: [ovaultAddress, assetOFTDeployment.address, shareOFTAdapterAddress], + args: [ovaultAddress, assetOFTDeployment.address, shareOFTAdapterAddress, refundOverpayAddress], log: true, skipIfAlreadyDeployed: true, }) diff --git a/examples/ovault-evm/test/composer/OVaultComposer_Base.t.sol b/examples/ovault-evm/test/composer/OVaultComposer_Base.t.sol index 28fc9333a7..f40ab11808 100644 --- a/examples/ovault-evm/test/composer/OVaultComposer_Base.t.sol +++ b/examples/ovault-evm/test/composer/OVaultComposer_Base.t.sol @@ -7,6 +7,7 @@ import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/Opti // OFT imports import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; import { SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; +import { EnforcedOptionParam } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppOptionsType3.sol"; import { OVaultComposer } from "@layerzerolabs/ovault-evm/contracts/OVaultComposer.sol"; @@ -45,9 +46,11 @@ contract OVaultComposerBaseTest is TestHelperOz5 { address public userA = makeAddr("userA"); address public userB = makeAddr("userB"); + address public refundOverpayAddress = makeAddr("refundOverpayAddress"); + address public arbEndpoint; address public arbExecutor = makeAddr("arbExecutor"); - bytes public OPTIONS_LZRECEIVE_2M = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0); + bytes public OPTIONS_LZRECEIVE_100k = OptionsBuilder.newOptions().addExecutorLzReceiveOption(100_000, 0); uint256 public constant INITIAL_BALANCE = 100 ether; uint256 public constant TOKENS_TO_SEND = 1 ether; @@ -73,7 +76,12 @@ contract OVaultComposerBaseTest is TestHelperOz5 { /// Now the "expansion" is for the arb vault and share ofts on other networks. oVault_arb = new MockOVault("arbShare", "arbShare", address(assetOFT_arb)); shareOFT_arb = new MockOFTAdapter(address(oVault_arb), address(endpoints[ARB_EID]), address(this)); - OVaultComposerArb = new OVaultComposer(address(oVault_arb), address(assetOFT_arb), address(shareOFT_arb)); + OVaultComposerArb = new OVaultComposer( + address(oVault_arb), + address(assetOFT_arb), + address(shareOFT_arb), + refundOverpayAddress + ); /// Deploy the Share OFTs on other networks - these are NOT lockbox adapters. shareOFT_eth = new MockOFT("ethShare", "ethShare", address(endpoints[ETH_EID]), address(this)); @@ -98,6 +106,13 @@ contract OVaultComposerBaseTest is TestHelperOz5 { deal(arbExecutor, INITIAL_BALANCE); deal(arbEndpoint, INITIAL_BALANCE); + + EnforcedOptionParam[] memory enforcedOptions = new EnforcedOptionParam[](2); + enforcedOptions[0] = EnforcedOptionParam({ eid: ETH_EID, msgType: 1, options: OPTIONS_LZRECEIVE_100k }); + enforcedOptions[1] = EnforcedOptionParam({ eid: POL_EID, msgType: 1, options: OPTIONS_LZRECEIVE_100k }); + + assetOFT_arb.setEnforcedOptions(enforcedOptions); + shareOFT_arb.setEnforcedOptions(enforcedOptions); } function _createComposePayload( diff --git a/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol b/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol index 6653ba1643..da4e6b9bd1 100644 --- a/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol +++ b/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol @@ -14,6 +14,7 @@ import { OVaultComposer } from "@layerzerolabs/ovault-evm/contracts/OVaultCompos import { OVaultComposerBaseTest } from "./OVaultComposer_Base.t.sol"; import { console } from "forge-std/console.sol"; + contract OVaultComposerUnitTest is OVaultComposerBaseTest { using OptionsBuilder for bytes; @@ -40,19 +41,11 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { OVaultComposerArb.lzCompose{ value: 1 ether }(_oft, _randomGUID(), "", arbExecutor, ""); } - function test_lzCompose_pass() public { + function test_lzCompose_pass_dst_not_hub() 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, - "", - "" - ); + SendParam memory internalSendParam = SendParam(POL_EID, addressToBytes32(userA), TOKENS_TO_SEND, 0, "", "", ""); bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); @@ -80,7 +73,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(shareOFT_arb)), TOKENS_TO_SEND); } - function test_lzCompose_pass_on_hub() public { + function test_lzCompose_pass_dst_is_hub() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); @@ -89,7 +82,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userA), TOKENS_TO_SEND, 0, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -141,7 +134,8 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { address oft, SendParam memory sendParam, address refundOFT, - SendParam memory refundSendParam + SendParam memory refundSendParam, + ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); @@ -154,7 +148,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEmpty(sendParam); - OVaultComposerArb.refund{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.refund(guid); verifyPackets(ETH_EID, address(assetOFT_arb)); assertEq(assetOFT_arb.balanceOf(userA), userBBalanceEth, "userA should have the same asset amount on Ethereum"); @@ -166,15 +160,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { uint256 userBBalanceEth = assetOFT_arb.balanceOf(userB); - SendParam memory internalSendParam = SendParam( - BAD_EID, - addressToBytes32(userB), - TOKENS_TO_SEND, - 0, - OPTIONS_LZRECEIVE_2M, - "", - "" - ); + SendParam memory internalSendParam = SendParam(BAD_EID, addressToBytes32(userB), TOKENS_TO_SEND, 0, "", "", ""); bytes memory composePayload = abi.encode(internalSendParam); bytes memory composeMsg = _createComposePayload(ETH_EID, composePayload, TOKENS_TO_SEND, userA); @@ -197,7 +183,8 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { address oft, SendParam memory sendParam, address refundOFT, - SendParam memory refundSendParam + SendParam memory refundSendParam, + ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); @@ -214,7 +201,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(sendParam, expectedSendParam); - OVaultComposerArb.refund{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.refund(guid); verifyPackets(ETH_EID, address(assetOFT_arb)); assertEq(assetOFT_arb.balanceOf(userA), userBBalanceEth, "userA should have the same asset amount on Ethereum"); @@ -229,7 +216,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userB), TOKENS_TO_SEND, TOKENS_TO_SEND + 1, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -251,7 +238,7 @@ 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.CanRetryWithSwapOrRefund)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRefundOrRetryWithSwap)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); @@ -260,7 +247,8 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { address oft, SendParam memory sendParam, address refundOFT, - SendParam memory refundSendParam + SendParam memory refundSendParam, + ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(assetOFT_arb), "refundOFT should be assetOFT_arb"); @@ -284,15 +272,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); - SendParam memory internalSendParam = SendParam( - POL_EID, - addressToBytes32(userB), - TOKENS_TO_SEND, - 0, - OPTIONS_LZRECEIVE_2M, - "", - "" - ); + SendParam memory internalSendParam = SendParam(POL_EID, addressToBytes32(userB), TOKENS_TO_SEND, 0, "", "", ""); bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); @@ -318,7 +298,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(oVault_arb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), oVault_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); - (address oft, SendParam memory sendParam, address refundOFT, ) = OVaultComposerArb.failedMessages(guid); + (address oft, SendParam memory sendParam, address refundOFT, , ) = OVaultComposerArb.failedMessages(guid); assertEq(refundOFT, address(0), "refundOFT should be 0 - not possible"); assertEq(oft, address(shareOFT_arb), "retry oft should be shareOFT_arb"); @@ -326,12 +306,12 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { 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(sendParam.extraOptions, "", "retry extraOptions should be empty"); verifyPackets(POL_EID, address(shareOFT_pol)); assertEq(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have the same shares on Polygon"); - OVaultComposerArb.retry{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.retry{ value: 1 ether }(guid, false); assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); @@ -342,7 +322,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertGt(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have more shares on Polygon"); } - function test_lzCompose_slippage_retry_with_swap() public { + function test_lzCompose_slippage_retry_with_swap_works() public { bytes32 guid = _randomGUID(); assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); @@ -355,7 +335,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userB), TOKENS_TO_SEND, targetAmount, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -374,7 +354,7 @@ 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.CanRetryWithSwapOrRefund)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRefundOrRetryWithSwap)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); @@ -383,7 +363,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(shareOFT_pol.balanceOf(userB), userBBalancePolygon, "userB should have the same shares on Polygon"); (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); - OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.retryWithSwap(guid, false); assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); @@ -411,7 +391,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userB), TOKENS_TO_SEND, targetAmount, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -430,21 +410,21 @@ 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.CanRetryWithSwapOrRefund)); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRefundOrRetryWithSwap)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); vm.expectRevert(); - OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); - assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRetryWithSwapOrRefund)); + OVaultComposerArb.retryWithSwap(guid, false); + assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.CanRefundOrRetryWithSwap)); assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); assertEq(oVault_arb.totalSupply(), 0); (uint256 mintAssets, uint256 mintShares) = _setTradeRatioAssetToShare(1, 2); - OVaultComposerArb.retryWithSwap{ value: 1 ether }(guid, OPTIONS_LZRECEIVE_2M); + OVaultComposerArb.retryWithSwap(guid, false); assertEq(uint256(OVaultComposerArb.failedGuidState(guid)), uint256(FailedState.NotFound)); From 3d1e8e527f3475a9250f2ff299329c7e6a4af0e2 Mon Sep 17 00:00:00 2001 From: shankar Date: Sat, 5 Jul 2025 10:57:31 +0000 Subject: [PATCH 23/30] retry and refund are atomic - revert when failedMessageTransfer reverts Signed-off-by: shankar --- .../ovault-evm/contracts/OVaultComposer.sol | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 3e94910a7f..f59650ebdd 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -178,8 +178,15 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { ) { emit Refunded(_guid, failedMessage.refundOFT); - } catch { + } catch (bytes memory errMsg) { emit SendFailed(_guid, failedMessage.refundOFT); + + if (errMsg.length > 0) { + assembly { + revert(add(errMsg, 32), mload(errMsg)) + } + } + revert(); } } @@ -203,8 +210,15 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { this.sendFailedMessage{ value: msg.value }(_guid, failedMessage.oft, sendParam, prePaidMsgValue, tx.origin) { emit Retried(_guid, failedMessage.oft); - } catch { + } catch (bytes memory errMsg) { emit SendFailed(_guid, failedMessage.oft); + + if (errMsg.length > 0) { + assembly { + revert(add(errMsg, 32), mload(errMsg)) + } + } + revert(); } } From 3983a95ee1331547b37352ca766ac4b3b33f1e5b Mon Sep 17 00:00:00 2001 From: shankar Date: Sat, 5 Jul 2025 11:07:41 +0000 Subject: [PATCH 24/30] accumulate msg.value supplied in retryWithSwap when sendFailedMessage fails Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index f59650ebdd..700af4fcf7 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -256,6 +256,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { { emit Retried(_guid, failedMessage.oft); } catch { + failedMessages[_guid].msgValue += msg.value; emit SendFailed(_guid, failedMessage.oft); } } From 4ba8b5516adea5876748166f2791a7432d7b9f4a Mon Sep 17 00:00:00 2001 From: shankar Date: Sat, 5 Jul 2025 11:10:16 +0000 Subject: [PATCH 25/30] setting prepaidMsgValue to 0 after sending the stored msgValue to REFUND address Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 700af4fcf7..5e45136071 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -200,10 +200,11 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { uint256 prePaidMsgValue = failedMessage.msgValue; if (removeExtraOptions) { + (bool sent, ) = payable(REFUND_OVERPAY_ADDRESS).call{ value: prePaidMsgValue }(""); + require(sent, "Failed to send Ether"); + sendParam.extraOptions = ""; prePaidMsgValue = 0; - - payable(REFUND_OVERPAY_ADDRESS).transfer(prePaidMsgValue); } try From dc38170b6b5836de28cb403f280a579980c32748 Mon Sep 17 00:00:00 2001 From: shankar Date: Sat, 5 Jul 2025 12:10:56 +0000 Subject: [PATCH 26/30] using _removeDust in _checkSlippage() to be equivalent to OFTCore::_debitView() Signed-off-by: shankar --- .../ovault-evm/contracts/OVaultComposer.sol | 47 +++++++++++++------ .../IOFTWithDecimalConversionRate.sol | 8 ++++ .../contracts/interfaces/IOVaultComposer.sol | 4 ++ 3 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 packages/ovault-evm/contracts/interfaces/IOFTWithDecimalConversionRate.sol diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 5e45136071..60e5329fef 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -7,12 +7,13 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -import { IOFT, SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; +import { SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol"; import { ILayerZeroEndpointV2 } from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroEndpointV2.sol"; import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol"; import { IOVaultComposer, FailedMessage, FailedState } from "./interfaces/IOVaultComposer.sol"; +import { IOFTWithDecimalConversionRate as IOFT } from "./interfaces/IOFTWithDecimalConversionRate.sol"; contract OVaultComposer is IOVaultComposer, ReentrancyGuard { using OFTComposeMsgCodec for bytes; @@ -27,6 +28,9 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { address public immutable REFUND_OVERPAY_ADDRESS; + uint256 public immutable ASSET_DECIMAL_CONVERSION_RATE; + uint256 public immutable SHARE_DECIMAL_CONVERSION_RATE; + mapping(bytes32 guid => FailedMessage) public failedMessages; constructor(address _ovault, address _assetOFT, address _shareOFT, address _refundOverpayAddress) { @@ -49,6 +53,9 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { ENDPOINT = address(IOAppCore(ASSET_OFT).endpoint()); HUB_EID = ILayerZeroEndpointV2(ENDPOINT).eid(); + ASSET_DECIMAL_CONVERSION_RATE = IOFT(_assetOFT).decimalConversionRate(); + SHARE_DECIMAL_CONVERSION_RATE = IOFT(_shareOFT).decimalConversionRate(); + // Approve the ovault to spend the share and asset tokens held by this contract IERC20(IOFT(_shareOFT).token()).approve(address(_ovault), type(uint256).max); IERC20(IOFT(_assetOFT).token()).approve(address(_ovault), type(uint256).max); @@ -145,12 +152,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { ) external nonReentrant returns (uint256 vaultAmount) { if (msg.sender != address(this)) revert OnlySelf(msg.sender); - vaultAmount = _executeOVaultAction(_oft, _amount); - - if (vaultAmount < _minAmountLD) { - /// @dev Will rollback on this function's storage changes (trade does not happen) - revert NotEnoughTargetTokens(vaultAmount, _minAmountLD); - } + vaultAmount = _executeOVaultActionWithSlippageCheck(_oft, _amount, _minAmountLD); } /// @dev External call for try...catch logic in lzCompose() @@ -232,14 +234,12 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { failedMessages[_guid].refundOFT = address(0); uint256 srcAmount = failedMessage.refundSendParam.amountLD; - uint256 minAmountLD = failedMessage.sendParam.minAmountLD; - uint256 vaultAmount = _executeOVaultAction(failedMessage.refundOFT, srcAmount); - - if (vaultAmount < minAmountLD) { - /// @dev Will rollback on this function's storage changes (trade does not happen) - revert NotEnoughTargetTokens(vaultAmount, minAmountLD); - } + uint256 vaultAmount = _executeOVaultActionWithSlippageCheck( + failedMessage.refundOFT, + srcAmount, + failedMessage.sendParam.minAmountLD + ); failedMessage.sendParam.amountLD = vaultAmount; emit SwappedTokens(_guid); @@ -284,12 +284,22 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { return _failedGuidState(failedMessage); } - function _executeOVaultAction(address _oft, uint256 _amount) internal returns (uint256 vaultAmount) { + function _executeOVaultActionWithSlippageCheck( + address _oft, + uint256 _amount, + uint256 _minAmountLD + ) internal returns (uint256 vaultAmount) { if (_oft == address(ASSET_OFT)) { vaultAmount = OVAULT.deposit(_amount, address(this)); } else { vaultAmount = OVAULT.redeem(_amount, address(this), address(this)); } + + /// @dev Remove dust before slippage check to be equivalent to OFTCore::_debitView() + uint256 vaultAmountLD = _removeDust(_oft, vaultAmount); + if (vaultAmountLD < _minAmountLD) { + revert NotEnoughTargetTokens(vaultAmountLD, _minAmountLD); + } } /// @dev Internal function to send the message to the target OFT @@ -329,6 +339,13 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { return _dstEid != HUB_EID && IOAppCore(_oft).peers(_dstEid) == bytes32(0); } + function _removeDust(address _oft, uint256 _amount) internal view returns (uint256) { + uint256 decimalConversionRate = _oft == address(ASSET_OFT) + ? ASSET_DECIMAL_CONVERSION_RATE + : SHARE_DECIMAL_CONVERSION_RATE; + return (_amount / decimalConversionRate) * decimalConversionRate; + } + function _failedGuidState(FailedMessage memory _failedMessage) internal pure returns (FailedState) { if (_failedMessage.refundOFT == address(0) && _failedMessage.oft == address(0)) { return FailedState.NotFound; diff --git a/packages/ovault-evm/contracts/interfaces/IOFTWithDecimalConversionRate.sol b/packages/ovault-evm/contracts/interfaces/IOFTWithDecimalConversionRate.sol new file mode 100644 index 0000000000..22746ed045 --- /dev/null +++ b/packages/ovault-evm/contracts/interfaces/IOFTWithDecimalConversionRate.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import { IOFT } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; + +interface IOFTWithDecimalConversionRate is IOFT { + function decimalConversionRate() external view returns (uint256); +} diff --git a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol index 5bfea41025..c0570f0f11 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -56,8 +56,12 @@ 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 HUB_EID() external view returns (uint32); function REFUND_OVERPAY_ADDRESS() external view returns (address); + function ASSET_DECIMAL_CONVERSION_RATE() external view returns (uint256); + function SHARE_DECIMAL_CONVERSION_RATE() external view returns (uint256); + /// ========================== FUNCTIONS ===================================== function refund(bytes32 guid) external payable; function retry(bytes32 guid, bool removeExtraOptions) external payable; From 7c392949ae5feba608e17155b726ca1fb160118d Mon Sep 17 00:00:00 2001 From: shankar Date: Sat, 5 Jul 2025 12:21:42 +0000 Subject: [PATCH 27/30] decouple _checkSlippage() and make it virtual along with _send() Signed-off-by: shankar --- .../ovault-evm/contracts/OVaultComposer.sol | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 60e5329fef..1fe66d279a 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -295,11 +295,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { vaultAmount = OVAULT.redeem(_amount, address(this), address(this)); } - /// @dev Remove dust before slippage check to be equivalent to OFTCore::_debitView() - uint256 vaultAmountLD = _removeDust(_oft, vaultAmount); - if (vaultAmountLD < _minAmountLD) { - revert NotEnoughTargetTokens(vaultAmountLD, _minAmountLD); - } + _checkSlippage(_oft, vaultAmount, _minAmountLD); } /// @dev Internal function to send the message to the target OFT @@ -311,7 +307,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { SendParam memory _sendParam, uint256 _totalMsgValue, address _refundOverpayAddress - ) internal { + ) internal virtual { if (_sendParam.dstEid == HUB_EID) { address token = IOFT(_oft).token(); address to = _sendParam.to.bytes32ToAddress(); @@ -346,6 +342,17 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { return (_amount / decimalConversionRate) * decimalConversionRate; } + /// @dev Remove dust before slippage check to be equivalent to OFTCore::_debitView() + /// @dev If the OFT has a Fee or anything that changes the tokens such that: + /// @dev dstChain.receivedAmount != srcChain.sentAmount, this will have to be overridden. + function _checkSlippage(address _oft, uint256 _amount, uint256 _minAmountLD) internal view virtual { + uint256 vaultAmountLD = _removeDust(_oft, _amount); + uint256 amountReceivedLD = vaultAmountLD; /// @dev Perform your adjustments here if needed (ex: Fee) + if (amountReceivedLD < _minAmountLD) { + revert NotEnoughTargetTokens(amountReceivedLD, _minAmountLD); + } + } + function _failedGuidState(FailedMessage memory _failedMessage) internal pure returns (FailedState) { if (_failedMessage.refundOFT == address(0) && _failedMessage.oft == address(0)) { return FailedState.NotFound; From 55d7b2695dec05d5523981b67dc1a926b7e5cbcb Mon Sep 17 00:00:00 2001 From: shankar Date: Sat, 5 Jul 2025 15:05:59 +0000 Subject: [PATCH 28/30] remove unnecessary try...catch in retry() and refund() Signed-off-by: shankar --- .../ovault-evm/contracts/OVaultComposer.sol | 45 +++++-------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 1fe66d279a..ec66140577 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -170,26 +170,14 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { FailedState f = _failedGuidState(failedMessage); if ((f != FailedState.CanOnlyRefund) && (f != FailedState.CanRefundOrRetryWithSwap)) revert CanNotRefund(_guid); - try - this.sendFailedMessage{ value: msg.value }( - _guid, - failedMessage.refundOFT, - failedMessage.refundSendParam, - failedMessage.msgValue, - REFUND_OVERPAY_ADDRESS - ) - { - emit Refunded(_guid, failedMessage.refundOFT); - } catch (bytes memory errMsg) { - emit SendFailed(_guid, failedMessage.refundOFT); - - if (errMsg.length > 0) { - assembly { - revert(add(errMsg, 32), mload(errMsg)) - } - } - revert(); - } + this.sendFailedMessage{ value: msg.value }( + _guid, + failedMessage.refundOFT, + failedMessage.refundSendParam, + failedMessage.msgValue, + REFUND_OVERPAY_ADDRESS + ); + emit Refunded(_guid, failedMessage.refundOFT); } /// @dev If removeExtraOptions is true, only enforcedOptions are used and the sender needs to pay the entire msg.value @@ -209,20 +197,8 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { prePaidMsgValue = 0; } - try - this.sendFailedMessage{ value: msg.value }(_guid, failedMessage.oft, sendParam, prePaidMsgValue, tx.origin) - { - emit Retried(_guid, failedMessage.oft); - } catch (bytes memory errMsg) { - emit SendFailed(_guid, failedMessage.oft); - - if (errMsg.length > 0) { - assembly { - revert(add(errMsg, 32), mload(errMsg)) - } - } - revert(); - } + this.sendFailedMessage{ value: msg.value }(_guid, failedMessage.oft, sendParam, prePaidMsgValue, tx.origin); + emit Retried(_guid, failedMessage.oft); } /// @dev Performs the ovault action for a failed swap. If we fail on the send then the GUID enters the retry state for manual execution. @@ -245,6 +221,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { emit SwappedTokens(_guid); /// @dev Regardless of the outcome of skipRetry and sendFailedMessage, the failedMessage is now in a RETRY only state. + /// @dev try..catch to accumulate the msg.value in the case of a failed send if (!skipRetry) { try this.sendFailedMessage{ value: msg.value }( From 12bdda07caea6adb4663c946577cccb12e6177aa Mon Sep 17 00:00:00 2001 From: shankar Date: Sun, 6 Jul 2025 12:19:53 +0000 Subject: [PATCH 29/30] set SendParam.amountLD = vaultAmount globally in retryWitSwap for retry Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 12 ++++++++---- .../contracts/interfaces/IOVaultComposer.sol | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index ec66140577..dbed7c0698 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -206,18 +206,22 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { FailedMessage memory failedMessage = failedMessages[_guid]; if (_failedGuidState(failedMessage) != FailedState.CanRefundOrRetryWithSwap) revert CanNotSwap(_guid); - /// @dev Disable refund. If this call reverts refund is still possible. - failedMessages[_guid].refundOFT = address(0); + if (skipRetry && msg.value > 0) revert NoMsgValueWhenSkippingRetry(); uint256 srcAmount = failedMessage.refundSendParam.amountLD; - uint256 vaultAmount = _executeOVaultActionWithSlippageCheck( + failedMessage.sendParam.amountLD = _executeOVaultActionWithSlippageCheck( failedMessage.refundOFT, srcAmount, failedMessage.sendParam.minAmountLD ); - failedMessage.sendParam.amountLD = vaultAmount; + /// @dev Perform global state update. + /// @dev Disable refund. If this call reverts refund is still possible. + /// @dev Store the vault out amount for the retry. + failedMessages[_guid].refundOFT = address(0); + failedMessages[_guid].sendParam.amountLD = failedMessage.sendParam.amountLD; + emit SwappedTokens(_guid); /// @dev Regardless of the outcome of skipRetry and sendFailedMessage, the failedMessage is now in a RETRY only state. diff --git a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol index c0570f0f11..42e885725f 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -50,6 +50,7 @@ interface IOVaultComposer is IOAppComposer { error CanNotSwap(bytes32 guid); error CanNotWithdraw(bytes32 guid); + error NoMsgValueWhenSkippingRetry(); error NotEnoughTargetTokens(uint256 amountLD, uint256 minAmountLD); /// ========================== GLOBAL VARIABLE FUNCTIONS ===================================== From 2655e7eba0734604a350f34f8367008f06ed1cf2 Mon Sep 17 00:00:00 2001 From: shankar Date: Sun, 6 Jul 2025 12:30:47 +0000 Subject: [PATCH 30/30] rename arg _oft to match lzCompose definition of _oft and refundOFT Signed-off-by: shankar --- packages/ovault-evm/contracts/OVaultComposer.sol | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index dbed7c0698..542c781e1d 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -67,7 +67,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } function lzCompose( - address _refundOFT, + address _refundOFT, /// @note The OFT used on refund, also the vaultIn token. bytes32 _guid, bytes calldata _message, address /*_executor*/, @@ -77,6 +77,7 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { if (_refundOFT != ASSET_OFT && _refundOFT != SHARE_OFT) revert OnlyOFT(_refundOFT); /// @dev Route to the correct target OFT + /// @note Also the vaultOut token. 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 @@ -146,13 +147,13 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { /// @dev External call for try...catch logic in lzCompose() function executeOVaultActionWithSlippageCheck( - address _oft, + address _refundOFT, uint256 _amount, uint256 _minAmountLD ) external nonReentrant returns (uint256 vaultAmount) { if (msg.sender != address(this)) revert OnlySelf(msg.sender); - vaultAmount = _executeOVaultActionWithSlippageCheck(_oft, _amount, _minAmountLD); + vaultAmount = _executeOVaultActionWithSlippageCheck(_refundOFT, _amount, _minAmountLD); } /// @dev External call for try...catch logic in lzCompose() @@ -266,17 +267,20 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { } function _executeOVaultActionWithSlippageCheck( - address _oft, + address _refundOFT, uint256 _amount, uint256 _minAmountLD ) internal returns (uint256 vaultAmount) { - if (_oft == address(ASSET_OFT)) { + address targetOFT = SHARE_OFT; + + if (_refundOFT == address(ASSET_OFT)) { vaultAmount = OVAULT.deposit(_amount, address(this)); } else { vaultAmount = OVAULT.redeem(_amount, address(this), address(this)); + targetOFT = ASSET_OFT; } - _checkSlippage(_oft, vaultAmount, _minAmountLD); + _checkSlippage(targetOFT, vaultAmount, _minAmountLD); } /// @dev Internal function to send the message to the target OFT