From 85215fd1c61db8c4c3e531d5f85b438e6c13f31c Mon Sep 17 00:00:00 2001 From: Danylo Moshenskyi Date: Fri, 10 Apr 2026 14:05:31 +0200 Subject: [PATCH] feat: weth --- .../NttManagerWithTokenPaymentExecutor.sol | 102 +++- .../interfaces/INttManagerWethUnwrap.sol | 9 + .../INttManagerWithTokenPaymentExecutor.sol | 23 + .../evm/executor/interfaces/IWETH.sol | 12 + .../test/MockNttManagerWethUnwrap.sol | 18 + .../evm/executor/test/MockWETHToken.sol | 24 + foundry.lock | 14 + foundry.toml | 1 + hardhat.config.ts | 1 + lib/example-ntt-with-executor-evm | 2 +- lib/native-token-transfers | 2 +- ...anager-with-token-payment-executor.test.ts | 453 ++++++++++++++++++ .../executor/token-payment-executor.helper.ts | 2 +- 13 files changed, 652 insertions(+), 11 deletions(-) create mode 100644 executor_contracts/evm/executor/interfaces/INttManagerWethUnwrap.sol create mode 100644 executor_contracts/evm/executor/interfaces/IWETH.sol create mode 100644 executor_contracts/evm/executor/test/MockNttManagerWethUnwrap.sol create mode 100644 executor_contracts/evm/executor/test/MockWETHToken.sol create mode 100644 foundry.lock diff --git a/executor_contracts/evm/executor/NttManagerWithTokenPaymentExecutor.sol b/executor_contracts/evm/executor/NttManagerWithTokenPaymentExecutor.sol index 71b3df0..1ea5a81 100644 --- a/executor_contracts/evm/executor/NttManagerWithTokenPaymentExecutor.sol +++ b/executor_contracts/evm/executor/NttManagerWithTokenPaymentExecutor.sol @@ -1,5 +1,5 @@ -// SPDX-License-Identifier: Apache 2 -pragma solidity ^0.8.19; +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -8,9 +8,10 @@ import "@example-vaa-executor/libraries/ExecutorMessages.sol"; import "@native-token-transfers/interfaces/INttManager.sol"; import "./interfaces/INttManagerWithTokenPaymentExecutor.sol"; +import "./interfaces/INttManagerWethUnwrap.sol"; import "./interfaces/ITokenPaymentExecutor.sol"; -string constant nttManagerWithExecutorVersion = "NttManagerWithTokenPaymentExecutor-0.0.1"; +string constant nttManagerWithExecutorVersion = "NttManagerWithTokenPaymentExecutor-0.0.2"; /// @title NttManagerWithTokenPaymentExecutor /// @notice The NttManagerWithTokenPaymentExecutor contract is a shim contract that initiates @@ -56,8 +57,10 @@ contract NttManagerWithTokenPaymentExecutor is INttManagerWithTokenPaymentExecut // Transfer the fee to the referrer. amount = payFee(token, amount, feeArgs, nttm, recipientChain); + // Approve the bridge to spend the tokens. + _maxApproveIfNeeded(token, nttManager, amount); + // Initiate the transfer. - SafeERC20.forceApprove(IERC20(token), nttManager, amount); msgId = nttm.transfer{ value: msg.value }( amount, recipientChain, @@ -67,7 +70,6 @@ contract NttManagerWithTokenPaymentExecutor is INttManagerWithTokenPaymentExecut encodedInstructions ); - uint256 executorFee = estimatedCost; // Avoid stack too deep error { // Approve custom token fee for executor. bytes32 universalTokenAddress; @@ -75,13 +77,89 @@ contract NttManagerWithTokenPaymentExecutor is INttManagerWithTokenPaymentExecut universalTokenAddress := calldataload(add(add(executorArgs, calldataload(add(executorArgs, 32))), 132)) } IERC20 tokenAddress = IERC20(address(uint160(uint256(universalTokenAddress)))); - SafeERC20.safeTransferFrom(tokenAddress, msg.sender, address(this), executorFee); - SafeERC20.forceApprove(tokenAddress, address(tokenPaymentExecutor), executorFee); + SafeERC20.safeTransferFrom(tokenAddress, msg.sender, address(this), estimatedCost); + SafeERC20.forceApprove(tokenAddress, address(tokenPaymentExecutor), estimatedCost); } // Generate the executor event. tokenPaymentExecutor.requestExecutionWithTokenPayment( - executorFee, + estimatedCost, + recipientChain, + nttm.getPeer(recipientChain).peerAddress, + executorArgs.refundAddress, + executorArgs.signedQuote, + ExecutorMessages.makeNTTv1Request( + chainId, + bytes32(uint256(uint160(address(nttm)))), + bytes32(uint256(msgId)) + ), + executorArgs.instructions + ); + + // Refund any excess value. + uint256 currentBalance = address(this).balance; + if (currentBalance > 0) { + (bool refundSuccessful, ) = payable(executorArgs.refundAddress).call{ value: currentBalance }(""); + if (!refundSuccessful) { + revert RefundFailed(currentBalance); + } + } + } + + function transferETH( + uint256 estimatedCost, + address nttManager, + uint256 amount, + uint16 recipientChain, + bytes32 recipientAddress, + bytes32 refundAddress, + bytes memory encodedInstructions, + ExecutorArgs calldata executorArgs, + FeeArgs calldata feeArgs + ) external payable returns (uint64 msgId) { + INttManagerWethUnwrap nttm = INttManagerWethUnwrap(nttManager); + IWETH weth = nttm.weth(); + address token = address(weth); + require(token != address(0), "WETH does not exist"); + + // This requires the amount + executionAmount to exactly equal msg.value + // because `transferTokensWithRelay` will revert if there is any extra. + require(msg.value >= amount, "Not enough msg value"); + uint256 remainingValue = msg.value - amount; + + // Deposit the amount to be transferred into WETH. + weth.deposit{ value: amount }(); + + // Transfer the fee to the referrer. + amount = payFee(token, amount, feeArgs, nttm, recipientChain); + + // Approve the bridge to spend the tokens. + _maxApproveIfNeeded(token, nttManager, amount); + + // Initiate the transfer. + msgId = nttm.transfer{ value: remainingValue }( + amount, + recipientChain, + recipientAddress, + refundAddress, + false, + encodedInstructions + ); + + { + // Approve custom token fee for executor. + bytes32 universalTokenAddress; + assembly { + universalTokenAddress := calldataload(add(add(executorArgs, calldataload(add(executorArgs, 32))), 132)) + } + IERC20 tokenAddress = IERC20(address(uint160(uint256(universalTokenAddress)))); + SafeERC20.safeTransferFrom(tokenAddress, msg.sender, address(this), estimatedCost); + SafeERC20.forceApprove(tokenAddress, address(tokenPaymentExecutor), estimatedCost); + } + + // Generate the executor event. + tokenPaymentExecutor.requestExecutionWithTokenPayment( + estimatedCost, recipientChain, nttm.getPeer(recipientChain).peerAddress, executorArgs.refundAddress, @@ -165,4 +243,12 @@ contract NttManagerWithTokenPaymentExecutor is INttManagerWithTokenPaymentExecut TrimmedAmount trimmedAmount = amount.trim(fromDecimals, toDecimals); newFee = trimmedAmount.untrim(fromDecimals); } + + function _maxApproveIfNeeded(address tokenAddr, address spender, uint256 amount) internal { + IERC20 token = IERC20(tokenAddr); + uint256 currentAllowance = token.allowance(address(this), spender); + if (currentAllowance < amount) { + SafeERC20.forceApprove(token, spender, type(uint256).max); + } + } } diff --git a/executor_contracts/evm/executor/interfaces/INttManagerWethUnwrap.sol b/executor_contracts/evm/executor/interfaces/INttManagerWethUnwrap.sol new file mode 100644 index 0000000..b869251 --- /dev/null +++ b/executor_contracts/evm/executor/interfaces/INttManagerWethUnwrap.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.23; + +import "native-token-transfers/evm/src/interfaces/INttManager.sol"; +import "./IWETH.sol"; + +interface INttManagerWethUnwrap is INttManager { + function weth() external returns (IWETH); +} diff --git a/executor_contracts/evm/executor/interfaces/INttManagerWithTokenPaymentExecutor.sol b/executor_contracts/evm/executor/interfaces/INttManagerWithTokenPaymentExecutor.sol index 42b10c7..39b2020 100644 --- a/executor_contracts/evm/executor/interfaces/INttManagerWithTokenPaymentExecutor.sol +++ b/executor_contracts/evm/executor/interfaces/INttManagerWithTokenPaymentExecutor.sol @@ -48,4 +48,27 @@ interface INttManagerWithTokenPaymentExecutor { ExecutorArgs calldata executorArgs, FeeArgs calldata feeArgs ) external payable returns (uint64 msgId); + + /// @notice Transfer a given amount to a recipient on a given chain using the Executor for relaying. + /// @param estimatedCost Delivery cost in custom token. + /// @param nttManager The NTT manager used for the transfer. + /// @param amount The amount to transfer. + /// @param recipientChain The Wormhole chain ID for the destination. + /// @param recipientAddress The recipient address. + /// @param refundAddress The address to which a refund for unussed gas is issued on the recipient chain. + /// @param encodedInstructions Additional instructions to be forwarded to the recipient chain. + /// @param executorArgs The arguments to be passed into the Executor. + /// @param feeArgs The arguments used to compute and pay the referrer fee. + /// @return msgId The resulting message ID of the transfer + function transferETH( + uint256 estimatedCost, + address nttManager, + uint256 amount, + uint16 recipientChain, + bytes32 recipientAddress, + bytes32 refundAddress, + bytes memory encodedInstructions, + ExecutorArgs calldata executorArgs, + FeeArgs calldata feeArgs + ) external payable returns (uint64 msgId); } diff --git a/executor_contracts/evm/executor/interfaces/IWETH.sol b/executor_contracts/evm/executor/interfaces/IWETH.sol new file mode 100644 index 0000000..61d6cff --- /dev/null +++ b/executor_contracts/evm/executor/interfaces/IWETH.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.23; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IWETH is IERC20 { + function deposit() external payable; + + function transfer(address to, uint256 value) external returns (bool); + + function withdraw(uint256) external; +} diff --git a/executor_contracts/evm/executor/test/MockNttManagerWethUnwrap.sol b/executor_contracts/evm/executor/test/MockNttManagerWethUnwrap.sol new file mode 100644 index 0000000..82927dc --- /dev/null +++ b/executor_contracts/evm/executor/test/MockNttManagerWethUnwrap.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.23; + +import "@native-token-transfers/NttManager/NttManagerWethUnwrap.sol"; +import "@native-token-transfers/interfaces/IManagerBase.sol"; + +contract MockNttManagerWethUnwrap is NttManagerWethUnwrap { + constructor( + address _token, + IManagerBase.Mode _mode, + uint16 _chainId, + uint64 _rateLimitDuration, + bool _skipRateLimiting, + address owner + ) NttManagerWethUnwrap(_token, _mode, _chainId, _rateLimitDuration, _skipRateLimiting) { + _transferOwnership(owner); + } +} diff --git a/executor_contracts/evm/executor/test/MockWETHToken.sol b/executor_contracts/evm/executor/test/MockWETHToken.sol new file mode 100644 index 0000000..4a1a546 --- /dev/null +++ b/executor_contracts/evm/executor/test/MockWETHToken.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.23; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockWETHToken is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address account, uint256 value) external { + _mint(account, value); + } + + function burn(address account, uint256 value) external { + _burn(account, value); + } + + function deposit() public payable { + _update(address(0), msg.sender, msg.value); + } + function withdraw(uint wad) public { + _update(msg.sender, address(0), wad); + payable(msg.sender).transfer(wad); + } +} diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..d353f5e --- /dev/null +++ b/foundry.lock @@ -0,0 +1,14 @@ +{ + "lib/example-messaging-executor": { + "rev": "575069616477efbec961bcfb77d7baf44e9f3baa" + }, + "lib/example-ntt-with-executor-evm": { + "rev": "e1de88385c7913c2e5df65c0889bc9a099558314" + }, + "lib/native-token-transfers": { + "rev": "25a5c3f89446be499a89d1c453db996f29de9290" + }, + "lib/wormhole-solidity-sdk": { + "rev": "b9e129e65d34827d92fceeed8c87d3ecdfc801d0" + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index d7cd544..46c4362 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,4 +8,5 @@ cache_path = 'cache_forge' evm_version = 'paris' optimizer = true optimizer_runs = 200 +via_ir = true ignored_error_codes = [] \ No newline at end of file diff --git a/hardhat.config.ts b/hardhat.config.ts index 9eb9d5b..a9a785d 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ enabled: true, runs: 200, }, + viaIR: true, }, }, paths: { diff --git a/lib/example-ntt-with-executor-evm b/lib/example-ntt-with-executor-evm index 217b245..e1de883 160000 --- a/lib/example-ntt-with-executor-evm +++ b/lib/example-ntt-with-executor-evm @@ -1 +1 @@ -Subproject commit 217b2457ef36c5b212d1a0ba7361362d38686171 +Subproject commit e1de88385c7913c2e5df65c0889bc9a099558314 diff --git a/lib/native-token-transfers b/lib/native-token-transfers index 9b2f42f..25a5c3f 160000 --- a/lib/native-token-transfers +++ b/lib/native-token-transfers @@ -1 +1 @@ -Subproject commit 9b2f42f7c6147320e19cb63a86f3c55f329b5580 +Subproject commit 25a5c3f89446be499a89d1c453db996f29de9290 diff --git a/test/evm/executor/ntt-manager-with-token-payment-executor.test.ts b/test/evm/executor/ntt-manager-with-token-payment-executor.test.ts index 0a041cf..244e677 100644 --- a/test/evm/executor/ntt-manager-with-token-payment-executor.test.ts +++ b/test/evm/executor/ntt-manager-with-token-payment-executor.test.ts @@ -45,6 +45,12 @@ describe("NttManagerWithTokenPaymentExecutor (unit tests)", () => { }); const token2Address = getAddress(token2.address); + // deploy weth token mock + const tokenWeth = await viem.deployContract("MockWETHToken", ["SomeCoinWeth", "WETH"], { + client: { wallet: deployer }, + }); + const tokenWethAddress = getAddress(tokenWeth.address); + // deploy receiver mock const receiver = await viem.deployContract("MockReceiver", [], { client: { wallet: deployer } }); const receiverAddress = getAddress(receiver.address); @@ -79,6 +85,24 @@ describe("NttManagerWithTokenPaymentExecutor (unit tests)", () => { }); const nttManagerAddress = getAddress(nttManager.address); + // deploy and config nttManagerWeth mock (same as above, just with weth support) + const nttManagerWeth = await viem.deployContract( + "MockNttManagerWethUnwrap", + [tokenWethAddress, 0, DEFAULT_SOURCE_ID, 0n, true, deployer.account.address], + { + client: { wallet: deployer }, + libraries: { + "project/lib/native-token-transfers/evm/src/libraries/TransceiverStructs.sol:TransceiverStructs": + structs.address, + }, + } + ); + await nttManagerWeth.write.setTransceiver([transceiver.address], { account: deployer.account }); + await nttManagerWeth.write.setPeer([DEFAULT_DESTINATION_ID, peerAddress, 18, 1_000_000_000_000_000_000n], { + account: deployer.account, + }); + const nttManagerWethAddress = getAddress(nttManagerWeth.address); + // deploy nttManagerWithTokenPaymentExecutor const nttManagerWithTokenPaymentExecutor = await viem.deployContract( "NttManagerWithTokenPaymentExecutor", @@ -107,12 +131,16 @@ describe("NttManagerWithTokenPaymentExecutor (unit tests)", () => { transceiver, nttManager, nttManagerAddress, + nttManagerWeth, + nttManagerWethAddress, nttManagerWithTokenPaymentExecutor, nttManagerWithTokenPaymentExecutorAddress, token, tokenAddress, token2, token2Address, + tokenWeth, + tokenWethAddress, receiver, receiverAddress, payeeAddress, @@ -325,6 +353,90 @@ describe("NttManagerWithTokenPaymentExecutor (unit tests)", () => { expect(payeeBalanceBefore).to.equal(payeeBalanceAfter - estimatedCost); }); + it("Should make NTT transfer, produce PaymentInToken event and call executor (same token for NTT and executor fee) with referrer fee", async () => { + const { + user, + executor, + tokenPaymentExecutor, + nttManager, + nttManagerWithTokenPaymentExecutor, + nttManagerWithTokenPaymentExecutorAddress, + transceiver, + receiverAddress, + nttManagerAddress, + refundAddress, + token, + tokenAddress, + payeeAddress, + unusedUsers, + } = await networkHelpers.loadFixture(deployRelayerFixture); + + const customFeeDbps = 10000n; + const customFeeReceiver = unusedUsers[0].account.address; + const customFee = calculateFee(nttTransferAmount, customFeeDbps); + + const signedQuote = encodeCustomTokenQuoteInstruction({ + ...customTokenQuoteInstructionMock, + tokenAddress, + payeeAddress, + }); + const gasInstructions = encodeGasInstructions(gasInstructionsMock); + const requestInstructions = encodeNttRequestInstruction({ + srcChain: DEFAULT_SOURCE_ID, + srcManager: nttManagerAddress, + messageId: addressToBytes32(toHex(msgId)), + }); + const userBalanceBefore = await token.read.balanceOf([user.account.address]); + const payeeBalanceBefore = await token.read.balanceOf([payeeAddress]); + + await transceiver.write.setQuote([transcieverQuote], { account: user.account }); + await token.write.approve([nttManagerWithTokenPaymentExecutorAddress, estimatedCost + nttTransferAmount], { + account: user.account, + }); + + const tx = nttManagerWithTokenPaymentExecutor.write.transfer( + [ + estimatedCost, + nttManagerAddress, + nttTransferAmount, + DEFAULT_DESTINATION_ID, + addressToBytes32(receiverAddress), + addressToBytes32(refundAddress), + encodedInstructions, + { refundAddress, signedQuote, instructions: gasInstructions }, + { dbps: Number(customFeeDbps), payee: customFeeReceiver }, + ], + { account: user.account, value: transcieverQuote } + ); + + await viem.assertions.emitWithArgs(tx, nttManager, "TransferSent", [ + addressToBytes32(receiverAddress).toLowerCase(), + addressToBytes32(refundAddress).toLowerCase(), + nttTransferAmount - customFee, + transcieverQuote, + DEFAULT_DESTINATION_ID, + msgId, + ]); + await viem.assertions.emitWithArgs(tx, tokenPaymentExecutor, "PaymentInToken", [tokenAddress, estimatedCost]); + await viem.assertions.emitWithArgs(tx, executor, "RequestForExecution", [ + DEFAULT_QUOTER_ADDRESS, + 0n, + DEFAULT_DESTINATION_ID, + peerAddress, + refundAddress, + signedQuote, + requestInstructions, + gasInstructions, + ]); + + const userBalanceAfter = await token.read.balanceOf([user.account.address]); + const payeeBalanceAfter = await token.read.balanceOf([payeeAddress]); + const customFeeReceiverBalance = await token.read.balanceOf([customFeeReceiver]); + expect(userBalanceBefore).to.equal(userBalanceAfter + estimatedCost + nttTransferAmount); + expect(payeeBalanceBefore).to.equal(payeeBalanceAfter - estimatedCost); + expect(customFeeReceiverBalance).to.equal(customFee); + }); + it("Should make NTT transfer, produce PaymentInToken event and call executor (different token for NTT and executor fee)", async () => { const { user, @@ -406,4 +518,345 @@ describe("NttManagerWithTokenPaymentExecutor (unit tests)", () => { expect(userBalanceToken2Before).to.equal(userBalanceToken2After + estimatedCost); }); }); + + describe("TransferETH", () => { + const gasInstructionsMock: Array = [{ gasLimit: 20_000n, msgValue: 0n }]; + const customTokenQuoteInstructionMock: CustomTokenQuoteInstruction = { + prefix: CUSTOM_TOKEN_FEE_PREFIX, + quoterAddress: DEFAULT_QUOTER_ADDRESS, + payeeAddress: `0x${"9".repeat(40)}`, + sourceChain: DEFAULT_SOURCE_ID, + destinationChain: DEFAULT_DESTINATION_ID, + expiryTime: unixTime() + 60n, + baseFee: DEFAULT_BASE_FEE, + destinationGasPrice: DEFAULT_DESTINATION_GAS_PRICE, + sourcePrice: DEFAULT_SOURCE_TOKEN_PRICE, + destinationPrice: DEFAULT_DESTINATION_TOKEN_PRICE, + tokenAddress: `0x${"8".repeat(40)}`, + signature: RANDOM_SIGNATURE, + }; + const estimatedCost = 500_000_000n; + const msgId = 0n; + const nttTransferAmount = 250_000_000_000_000n; + const encodedInstructions = "0x010003aaaeee"; + const transcieverQuote = 101_000n; + + it("Should throw an error, not enough allowance to pay executor fee", async () => { + const { + user, + nttManagerWithTokenPaymentExecutor, + nttManagerWithTokenPaymentExecutorAddress, + transceiver, + receiverAddress, + nttManagerWethAddress, + refundAddress, + token, + tokenAddress, + payeeAddress, + } = await networkHelpers.loadFixture(deployRelayerFixture); + + const signedQuote = encodeCustomTokenQuoteInstruction({ + ...customTokenQuoteInstructionMock, + tokenAddress, + payeeAddress, + }); + const gasInstructions = encodeGasInstructions(gasInstructionsMock); + await transceiver.write.setQuote([transcieverQuote], { account: user.account }); + await token.write.approve([nttManagerWithTokenPaymentExecutorAddress, estimatedCost - 1n], { + account: user.account, + }); + + await viem.assertions.revertWithCustomErrorWithArgs( + nttManagerWithTokenPaymentExecutor.write.transferETH( + [ + estimatedCost, + nttManagerWethAddress, + nttTransferAmount, + DEFAULT_DESTINATION_ID, + addressToBytes32(receiverAddress), + addressToBytes32(refundAddress), + encodedInstructions, + { refundAddress, signedQuote, instructions: gasInstructions }, + { dbps: 0, payee: user.account.address }, + ], + { account: user.account, value: transcieverQuote + nttTransferAmount } + ), + token, + "ERC20InsufficientAllowance", + [nttManagerWithTokenPaymentExecutorAddress, estimatedCost - 1n, estimatedCost] + ); + }); + + it("Should throw an error, nttManager token does not support Weth", async () => { + const { + user, + nttManagerWithTokenPaymentExecutor, + nttManagerWithTokenPaymentExecutorAddress, + transceiver, + receiverAddress, + nttManagerAddress, + refundAddress, + token, + tokenAddress, + payeeAddress, + } = await networkHelpers.loadFixture(deployRelayerFixture); + + const signedQuote = encodeCustomTokenQuoteInstruction({ + ...customTokenQuoteInstructionMock, + tokenAddress, + payeeAddress, + }); + const gasInstructions = encodeGasInstructions(gasInstructionsMock); + await transceiver.write.setQuote([transcieverQuote], { account: user.account }); + await token.write.approve([nttManagerWithTokenPaymentExecutorAddress, estimatedCost], { + account: user.account, + }); + + await viem.assertions.revert( + nttManagerWithTokenPaymentExecutor.write.transferETH( + [ + estimatedCost, + nttManagerAddress, + nttTransferAmount, + DEFAULT_DESTINATION_ID, + addressToBytes32(receiverAddress), + addressToBytes32(refundAddress), + encodedInstructions, + { refundAddress, signedQuote, instructions: gasInstructions }, + { dbps: 0, payee: user.account.address }, + ], + { account: user.account, value: transcieverQuote + nttTransferAmount } + ) + ); + }); + + it("Should throw an error when token address is not ERC20", async () => { + const { + user, + nttManagerWithTokenPaymentExecutor, + nttManagerWithTokenPaymentExecutorAddress, + transceiver, + receiverAddress, + nttManagerWethAddress, + refundAddress, + token, + payeeAddress, + } = await networkHelpers.loadFixture(deployRelayerFixture); + + const quoteTokenAddress: Hex = `0x${"3".repeat(40)}`; + + const signedQuote = encodeCustomTokenQuoteInstruction({ + ...customTokenQuoteInstructionMock, + tokenAddress: quoteTokenAddress, + payeeAddress, + }); + const gasInstructions = encodeGasInstructions(gasInstructionsMock); + await transceiver.write.setQuote([transcieverQuote], { account: user.account }); + await token.write.approve([nttManagerWithTokenPaymentExecutorAddress, estimatedCost], { + account: user.account, + }); + + await viem.assertions.revertWithCustomErrorWithArgs( + nttManagerWithTokenPaymentExecutor.write.transferETH( + [ + estimatedCost, + nttManagerWethAddress, + nttTransferAmount, + DEFAULT_DESTINATION_ID, + addressToBytes32(receiverAddress), + addressToBytes32(refundAddress), + encodedInstructions, + { refundAddress, signedQuote, instructions: gasInstructions }, + { dbps: 0, payee: user.account.address }, + ], + { account: user.account, value: transcieverQuote + nttTransferAmount } + ), + nttManagerWithTokenPaymentExecutor, + "SafeERC20FailedOperation", + [quoteTokenAddress] + ); + }); + + it("Should make NTT transfer, produce PaymentInToken event and call executor (different token for NTT and executor fee)", async () => { + const { + user, + executor, + tokenPaymentExecutor, + nttManagerWeth, + nttManagerWithTokenPaymentExecutor, + nttManagerWithTokenPaymentExecutorAddress, + transceiver, + receiverAddress, + nttManagerWethAddress, + refundAddress, + token, + tokenAddress, + payeeAddress, + } = await networkHelpers.loadFixture(deployRelayerFixture); + + const signedQuote = encodeCustomTokenQuoteInstruction({ + ...customTokenQuoteInstructionMock, + tokenAddress, + payeeAddress, + }); + const gasInstructions = encodeGasInstructions(gasInstructionsMock); + const requestInstructions = encodeNttRequestInstruction({ + srcChain: DEFAULT_SOURCE_ID, + srcManager: nttManagerWethAddress, + messageId: addressToBytes32(toHex(msgId)), + }); + const userBalanceBefore = await token.read.balanceOf([user.account.address]); + const payeeBalanceBefore = await token.read.balanceOf([payeeAddress]); + + await transceiver.write.setQuote([transcieverQuote], { account: user.account }); + await token.write.approve([nttManagerWithTokenPaymentExecutorAddress, estimatedCost], { + account: user.account, + }); + + const publicClient = await viem.getPublicClient(); + const userEthBalanceBefore = await publicClient.getBalance({ address: user.account.address }); + + const tx = nttManagerWithTokenPaymentExecutor.write.transferETH( + [ + estimatedCost, + nttManagerWethAddress, + nttTransferAmount, + DEFAULT_DESTINATION_ID, + addressToBytes32(receiverAddress), + addressToBytes32(refundAddress), + encodedInstructions, + { refundAddress, signedQuote, instructions: gasInstructions }, + { dbps: 0, payee: user.account.address }, + ], + { account: user.account, value: transcieverQuote + nttTransferAmount } + ); + + await viem.assertions.emitWithArgs(tx, nttManagerWeth, "TransferSent", [ + addressToBytes32(receiverAddress).toLowerCase(), + addressToBytes32(refundAddress).toLowerCase(), + nttTransferAmount, + transcieverQuote, + DEFAULT_DESTINATION_ID, + msgId, + ]); + await viem.assertions.emitWithArgs(tx, tokenPaymentExecutor, "PaymentInToken", [tokenAddress, estimatedCost]); + await viem.assertions.emitWithArgs(tx, executor, "RequestForExecution", [ + DEFAULT_QUOTER_ADDRESS, + 0n, + DEFAULT_DESTINATION_ID, + peerAddress, + refundAddress, + signedQuote, + requestInstructions, + gasInstructions, + ]); + + const userBalanceAfter = await token.read.balanceOf([user.account.address]); + const payeeBalanceAfter = await token.read.balanceOf([payeeAddress]); + expect(userBalanceBefore).to.equal(userBalanceAfter + estimatedCost); + expect(payeeBalanceBefore).to.equal(payeeBalanceAfter - estimatedCost); + + const userEthBalanceAfter = await publicClient.getBalance({ address: user.account.address }); + const receipt = await publicClient.waitForTransactionReceipt({ hash: await tx }); + const gasSpent = receipt.gasUsed * receipt.effectiveGasPrice; + expect(userEthBalanceBefore).to.equal(userEthBalanceAfter + transcieverQuote + nttTransferAmount + gasSpent); + }); + + it("Should make NTT transfer, produce PaymentInToken event and call executor (different token for NTT and executor fee) with custom fee", async () => { + const { + user, + executor, + tokenPaymentExecutor, + nttManagerWeth, + nttManagerWithTokenPaymentExecutor, + nttManagerWithTokenPaymentExecutorAddress, + transceiver, + receiverAddress, + nttManagerWethAddress, + refundAddress, + token, + tokenAddress, + tokenWeth, + payeeAddress, + unusedUsers, + } = await networkHelpers.loadFixture(deployRelayerFixture); + + const customFeeDbps = 10000n; + const customFeeReceiver = unusedUsers[0].account.address; + const customFee = calculateFee(nttTransferAmount, customFeeDbps); + + const signedQuote = encodeCustomTokenQuoteInstruction({ + ...customTokenQuoteInstructionMock, + tokenAddress, + payeeAddress, + }); + const gasInstructions = encodeGasInstructions(gasInstructionsMock); + const requestInstructions = encodeNttRequestInstruction({ + srcChain: DEFAULT_SOURCE_ID, + srcManager: nttManagerWethAddress, + messageId: addressToBytes32(toHex(msgId)), + }); + const userBalanceBefore = await token.read.balanceOf([user.account.address]); + const payeeBalanceBefore = await token.read.balanceOf([payeeAddress]); + + await transceiver.write.setQuote([transcieverQuote], { account: user.account }); + await token.write.approve([nttManagerWithTokenPaymentExecutorAddress, estimatedCost], { + account: user.account, + }); + + const publicClient = await viem.getPublicClient(); + const userEthBalanceBefore = await publicClient.getBalance({ address: user.account.address }); + + const tx = nttManagerWithTokenPaymentExecutor.write.transferETH( + [ + estimatedCost, + nttManagerWethAddress, + nttTransferAmount, + DEFAULT_DESTINATION_ID, + addressToBytes32(receiverAddress), + addressToBytes32(refundAddress), + encodedInstructions, + { refundAddress, signedQuote, instructions: gasInstructions }, + { dbps: Number(customFeeDbps), payee: customFeeReceiver }, + ], + { account: user.account, value: transcieverQuote + nttTransferAmount } + ); + + await viem.assertions.emitWithArgs(tx, nttManagerWeth, "TransferSent", [ + addressToBytes32(receiverAddress).toLowerCase(), + addressToBytes32(refundAddress).toLowerCase(), + nttTransferAmount - customFee, + transcieverQuote, + DEFAULT_DESTINATION_ID, + msgId, + ]); + await viem.assertions.emitWithArgs(tx, tokenPaymentExecutor, "PaymentInToken", [tokenAddress, estimatedCost]); + await viem.assertions.emitWithArgs(tx, executor, "RequestForExecution", [ + DEFAULT_QUOTER_ADDRESS, + 0n, + DEFAULT_DESTINATION_ID, + peerAddress, + refundAddress, + signedQuote, + requestInstructions, + gasInstructions, + ]); + + const userBalanceAfter = await token.read.balanceOf([user.account.address]); + const payeeBalanceAfter = await token.read.balanceOf([payeeAddress]); + const customFeeReceiverBalance = await tokenWeth.read.balanceOf([customFeeReceiver]); + expect(userBalanceBefore).to.equal(userBalanceAfter + estimatedCost); + expect(payeeBalanceBefore).to.equal(payeeBalanceAfter - estimatedCost); + expect(customFeeReceiverBalance).to.equal(customFee); + + const userEthBalanceAfter = await publicClient.getBalance({ address: user.account.address }); + const receipt = await publicClient.waitForTransactionReceipt({ hash: await tx }); + const gasSpent = receipt.gasUsed * receipt.effectiveGasPrice; + expect(userEthBalanceBefore).to.equal(userEthBalanceAfter + transcieverQuote + nttTransferAmount + gasSpent); + }); + }); }); + +function calculateFee(amount: bigint, basisPoints: bigint) { + return (amount * basisPoints) / 100000n; +} diff --git a/test/evm/executor/token-payment-executor.helper.ts b/test/evm/executor/token-payment-executor.helper.ts index f6c03e4..8288b90 100644 --- a/test/evm/executor/token-payment-executor.helper.ts +++ b/test/evm/executor/token-payment-executor.helper.ts @@ -18,7 +18,7 @@ export const REQ_VAA_V1 = stringToHex("ERV1"); export const REQ_NTT_V1 = stringToHex("ERN1"); export const DEFAULT_TOKEN_PAYMENT_EXECUTOR_VERSION = "TokenPaymentExecutor-0.0.1"; -export const DEFAULT_NTT_MANAGER_WITH_TOKEN_PAYMENT_EXECUTOR_VERSION = "NttManagerWithTokenPaymentExecutor-0.0.1"; +export const DEFAULT_NTT_MANAGER_WITH_TOKEN_PAYMENT_EXECUTOR_VERSION = "NttManagerWithTokenPaymentExecutor-0.0.2"; export const DEFAULT_QUOTER_ADDRESS = "0xdaC17f958d2eE523a2206206994597C13D831bC7"; export const DEFAULT_SOURCE_ID = 321; export const DEFAULT_SOURCE_TOKEN_PRICE = 8_000_000_000_000_000_000n;