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/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..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( @@ -139,6 +154,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..da4e6b9bd1 100644 --- a/examples/ovault-evm/test/composer/OVaultComposer_Unit.t.sol +++ b/examples/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, + "", "", "" ); @@ -121,9 +113,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"); @@ -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"); @@ -153,21 +147,20 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(refundSendParam.extraOptions, "", "refund extraOptions should be empty"); assertEmpty(sendParam); + + OVaultComposerArb.refund(guid); + + 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); - SendParam memory internalSendParam = SendParam( - BAD_EID, - addressToBytes32(userB), - TOKENS_TO_SEND, - 0, - OPTIONS_LZRECEIVE_2M, - "", - "" - ); + uint256 userBBalanceEth = assetOFT_arb.balanceOf(userB); + + 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); @@ -190,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"); @@ -206,9 +200,14 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { expectedSendParam.amountLD = 0; assertEq(sendParam, expectedSendParam); + + OVaultComposerArb.refund(guid); + + 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); @@ -217,7 +216,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userB), TOKENS_TO_SEND, TOKENS_TO_SEND + 1, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -239,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.CanRetryWithSwap)); + 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); @@ -248,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"); @@ -266,19 +266,13 @@ 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); - SendParam memory internalSendParam = SendParam( - POL_EID, - addressToBytes32(userB), - TOKENS_TO_SEND, - 0, - OPTIONS_LZRECEIVE_2M, - "", - "" - ); + uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); + + SendParam memory internalSendParam = SendParam(POL_EID, addressToBytes32(userB), TOKENS_TO_SEND, 0, "", "", ""); bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); @@ -304,12 +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, - 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"); @@ -317,27 +306,92 @@ 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"); - 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"); + 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, false); + + 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_works() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + (uint256 targetAmount, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); + + uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + targetAmount, + "", + "", + "" + ); + + 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.CanRefundOrRetryWithSwap)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + 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(guid, false); + + 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 + ); + + 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 { + function test_lzCompose_slippage_retry_with_swap_failed_retains_transaction() 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, + "", "", "" ); @@ -356,21 +410,30 @@ 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.CanRefundOrRetryWithSwap)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.expectRevert(); + 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)); 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 ); diff --git a/packages/ovault-evm/contracts/OVault.sol b/packages/ovault-evm/contracts/OVault.sol index befcaae49b..48b86832e4 100644 --- a/packages/ovault-evm/contracts/OVault.sol +++ b/packages/ovault-evm/contracts/OVault.sol @@ -2,28 +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) {} - - /// @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); - } + constructor( + string memory _name, + string memory _symbol, + address _asset + ) ERC4626(IERC20(_asset)) ERC20(_name, _symbol) {} } diff --git a/packages/ovault-evm/contracts/OVaultComposer.sol b/packages/ovault-evm/contracts/OVaultComposer.sol index 150755b6e6..542c781e1d 100644 --- a/packages/ovault-evm/contracts/OVaultComposer.sol +++ b/packages/ovault-evm/contracts/OVaultComposer.sol @@ -3,18 +3,22 @@ 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"; +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; using OFTComposeMsgCodec for bytes32; + using SafeERC20 for IERC20; address public immutable ASSET_OFT; // any OFT address public immutable SHARE_OFT; // lockbox adapter @@ -22,9 +26,14 @@ contract OVaultComposer is IOVaultComposer, ReentrancyGuard { address public immutable ENDPOINT; uint32 public immutable HUB_EID; + 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) { + constructor(address _ovault, address _assetOFT, address _shareOFT, address _refundOverpayAddress) { OVAULT = IERC4626(_ovault); ASSET_OFT = _assetOFT; SHARE_OFT = _shareOFT; @@ -33,19 +42,32 @@ 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(); + 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); // 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( - address _refundOFT, + address _refundOFT, /// @note The OFT used on refund, also the vaultIn token. bytes32 _guid, bytes calldata _message, address /*_executor*/, @@ -55,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 @@ -77,38 +100,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 { - /// @dev A failed send can happen due to not enough msg.value - /// @dev Since we have the target tokens in the composer, we can retry with more gas. - failedMessages[_guid] = FailedMessage(oft, sendParam, address(0), refundSendParam); - emit SendFailed(_guid, oft); /// @dev This can be due to msg.value or layerzero config (dvn config, etc) + SendParam memory emptySendParam; + /// @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; } } @@ -120,122 +147,210 @@ 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 = _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(_refundOFT, _amount, _minAmountLD); } /// @dev External call for try...catch logic in lzCompose() function send(address _oft, SendParam calldata _sendParam) external payable nonReentrant { if (msg.sender != address(this)) revert OnlySelf(msg.sender); - - /// @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); - 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 - 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]; - SendParam memory refundSendParam = failedMessage.sendParam; - if (failedGuidState(_guid) != FailedState.CanOnlyRefund) revert CanNotRefund(_guid); - refundSendParam.extraOptions = _extraOptions; + FailedState f = _failedGuidState(failedMessage); + if ((f != FailedState.CanOnlyRefund) && (f != FailedState.CanRefundOrRetryWithSwap)) revert CanNotRefund(_guid); - delete failedMessages[_guid]; - _send(failedMessage.refundOFT, refundSendParam); + this.sendFailedMessage{ value: msg.value }( + _guid, + failedMessage.refundOFT, + failedMessage.refundSendParam, + failedMessage.msgValue, + REFUND_OVERPAY_ADDRESS + ); emit Refunded(_guid, failedMessage.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 { + /// @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(_guid) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); + if (_failedGuidState(failedMessage) != FailedState.CanOnlyRetry) revert CanNotRetry(_guid); SendParam memory sendParam = failedMessage.sendParam; + uint256 prePaidMsgValue = failedMessage.msgValue; - sendParam.extraOptions = _extraOptions; + if (removeExtraOptions) { + (bool sent, ) = payable(REFUND_OVERPAY_ADDRESS).call{ value: prePaidMsgValue }(""); + require(sent, "Failed to send Ether"); - delete failedMessages[_guid]; - _send(failedMessage.oft, sendParam); + sendParam.extraOptions = ""; + prePaidMsgValue = 0; + } + + this.sendFailedMessage{ value: msg.value }(_guid, failedMessage.oft, sendParam, prePaidMsgValue, tx.origin); emit Retried(_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 - ex: dvn config - function retryWithSwap(bytes32 _guid, bytes calldata _extraOptions) external payable { + /// @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(_guid) != FailedState.CanRetryWithSwap) revert CanNotRetry(_guid); - - SendParam memory sendParam = failedMessage.sendParam; - sendParam.extraOptions = _extraOptions; + if (_failedGuidState(failedMessage) != FailedState.CanRefundOrRetryWithSwap) revert CanNotSwap(_guid); + + if (skipRetry && msg.value > 0) revert NoMsgValueWhenSkippingRetry(); + + uint256 srcAmount = failedMessage.refundSendParam.amountLD; + + failedMessage.sendParam.amountLD = _executeOVaultActionWithSlippageCheck( + failedMessage.refundOFT, + srcAmount, + failedMessage.sendParam.minAmountLD + ); + + /// @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. + /// @dev try..catch to accumulate the msg.value in the case of a failed send + if (!skipRetry) { + try + this.sendFailedMessage{ value: msg.value }( + _guid, + failedMessage.oft, + failedMessage.sendParam, + failedMessage.msgValue, + tx.origin + ) + { + emit Retried(_guid, failedMessage.oft); + } catch { + failedMessages[_guid].msgValue += msg.value; + emit SendFailed(_guid, failedMessage.oft); + } + } + } - uint256 amountLd = failedMessage.refundSendParam.amountLD; + function sendFailedMessage( + bytes32 _guid, + address _oft, + SendParam memory _sendParam, + uint256 _prePaidMsgValue, + address _refundOverpayAddress + ) external payable { + if (msg.sender != address(this)) revert OnlySelf(msg.sender); delete failedMessages[_guid]; - sendParam.amountLD = _executeOVaultAction(failedMessage.refundOFT, amountLd); - _send(failedMessage.oft, sendParam); - emit Sent(_guid, failedMessage.oft); + uint256 totalMsgValue = _prePaidMsgValue + msg.value; + _send(_oft, _sendParam, totalMsgValue, _refundOverpayAddress); } - /// @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); + /// @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)) { + function _executeOVaultActionWithSlippageCheck( + address _refundOFT, + uint256 _amount, + uint256 _minAmountLD + ) internal returns (uint256 vaultAmount) { + 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(targetOFT, vaultAmount, _minAmountLD); + } + + /// @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 intended refund receiver then this function will have to be overridden. + /// @dev Slippage check happens at the OFT for : amountLD >= sendParam.minAmountLD + /// @dev Handles the case where the dstEid is the HUB chain + function _send( + address _oft, + SendParam memory _sendParam, + uint256 _totalMsgValue, + address _refundOverpayAddress + ) internal virtual { + 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"); + } + + emit SentOnHub(to, _oft, amountLD); + } else { + IOFT(_oft).send{ value: _totalMsgValue }( + _sendParam, + MessagingFee(_totalMsgValue, 0), + _refundOverpayAddress + ); } } - /// @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); } - /// @dev Helper to view the state of a failed message - function failedGuidState(bytes32 _guid) public view returns (FailedState) { - FailedMessage memory failedMessage = failedMessages[_guid]; + 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; + } - if (failedMessage.refundOFT == address(0) && failedMessage.oft == address(0)) { + /// @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; } - 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.CanRefundOrRetryWithSwap; } + receive() external payable {} } diff --git a/packages/ovault-evm/contracts/OVaultUpgradeable.sol b/packages/ovault-evm/contracts/OVaultUpgradeable.sol index 842df83eac..ae1df08ef4 100644 --- a/packages/ovault-evm/contracts/OVaultUpgradeable.sol +++ b/packages/ovault-evm/contracts/OVaultUpgradeable.sol @@ -2,32 +2,12 @@ pragma solidity ^0.8.20; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; contract OVaultUpgradeable is ERC4626Upgradeable { - using SafeERC20 for IERC20; - using Math for uint256; - /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } - - /// @dev Using solmate's implementation to work around rounding issues on initial minting - function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) { - uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. - - return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), rounding); - } - - /// @dev Using solmate's implementation to work around rounding issues on initial minting - function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view override returns (uint256) { - uint256 supply = totalSupply(); // Saves an extra SLOAD if totalSupply is non-zero. - - return supply == 0 ? shares : shares.mulDiv(totalAssets(), supply, rounding); - } } diff --git a/packages/ovault-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 ff7cfaecf4..42e885725f 100644 --- a/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol +++ b/packages/ovault-evm/contracts/interfaces/IOVaultComposer.sol @@ -2,62 +2,79 @@ 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; SendParam sendParam; address refundOFT; SendParam refundSendParam; + uint256 msgValue; } enum FailedState { NotFound, CanOnlyRefund, CanOnlyRetry, - CanRetryWithSwap + 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 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); /// ========================== 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); 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 NoMsgValueWhenSkippingRetry(); 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 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 executeOVaultActionWithSlippageCheck( + 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, - 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; + SendParam memory _sendParam, + uint256 _prePaidMsgValue, + address _refundOverpayAddress + ) external payable; function failedGuidState(bytes32 guid) external view returns (FailedState); 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..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( @@ -139,6 +154,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..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, + "", "", "" ); @@ -121,9 +113,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"); @@ -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"); @@ -153,21 +147,20 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { assertEq(refundSendParam.extraOptions, "", "refund extraOptions should be empty"); assertEmpty(sendParam); + + OVaultComposerArb.refund(guid); + + 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); - SendParam memory internalSendParam = SendParam( - BAD_EID, - addressToBytes32(userB), - TOKENS_TO_SEND, - 0, - OPTIONS_LZRECEIVE_2M, - "", - "" - ); + uint256 userBBalanceEth = assetOFT_arb.balanceOf(userB); + + 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); @@ -190,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"); @@ -206,9 +200,14 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { expectedSendParam.amountLD = 0; assertEq(sendParam, expectedSendParam); + + OVaultComposerArb.refund(guid); + + 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); @@ -217,7 +216,7 @@ contract OVaultComposerUnitTest is OVaultComposerBaseTest { addressToBytes32(userB), TOKENS_TO_SEND, TOKENS_TO_SEND + 1, - OPTIONS_LZRECEIVE_2M, + "", "", "" ); @@ -239,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.CanRetryWithSwap)); + 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); @@ -248,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"); @@ -266,19 +266,13 @@ 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); - SendParam memory internalSendParam = SendParam( - POL_EID, - addressToBytes32(userB), - TOKENS_TO_SEND, - 0, - OPTIONS_LZRECEIVE_2M, - "", - "" - ); + uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); + + SendParam memory internalSendParam = SendParam(POL_EID, addressToBytes32(userB), TOKENS_TO_SEND, 0, "", "", ""); bytes memory composeMsg = _createComposePayload(ETH_EID, internalSendParam, TOKENS_TO_SEND, userA); @@ -304,12 +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, - 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"); @@ -317,27 +306,92 @@ 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"); - 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"); + 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, false); + + 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_works() public { + bytes32 guid = _randomGUID(); + assetOFT_arb.mint(address(OVaultComposerArb), TOKENS_TO_SEND); + + (uint256 targetAmount, ) = _removeDustWithOffset(TOKENS_TO_SEND * 2, -1); + + uint256 userBBalancePolygon = shareOFT_pol.balanceOf(userB); + + SendParam memory internalSendParam = SendParam( + POL_EID, + addressToBytes32(userB), + TOKENS_TO_SEND, + targetAmount, + "", + "", + "" + ); + + 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.CanRefundOrRetryWithSwap)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + 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(guid, false); + + 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 + ); + + 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 { + function test_lzCompose_slippage_retry_with_swap_failed_retains_transaction() 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, + "", "", "" ); @@ -356,21 +410,30 @@ 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.CanRefundOrRetryWithSwap)); + + assertEq(assetOFT_arb.totalSupply(), assetOFT_arb.balanceOf(address(OVaultComposerArb)), TOKENS_TO_SEND); + assertEq(oVault_arb.totalSupply(), 0); + + vm.expectRevert(); + 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)); 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 );