From ee03ca283ff9f160201166dd75b50cb5b8e6360a Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 18 Feb 2026 18:08:33 +0100 Subject: [PATCH 1/4] feat: add oft tip 20 contract implementation and tests --- packages/oft-evm/contracts/OFTTIP20.sol | 88 +++++++ .../contracts/interfaces/IEndpointV2Alt.sol | 7 + .../contracts/interfaces/ITIP20Minter.sol | 9 + packages/oft-evm/test/TIP20OFT.t.sol | 236 ++++++++++++++++++ .../oft-evm/test/lib/TIP20OFTMockCodec.sol | 11 + packages/oft-evm/test/mocks/TIP20OFTMock.sol | 29 +++ .../oft-evm/test/mocks/TIP20TokenMock.sol | 24 ++ 7 files changed, 404 insertions(+) create mode 100644 packages/oft-evm/contracts/OFTTIP20.sol create mode 100644 packages/oft-evm/contracts/interfaces/IEndpointV2Alt.sol create mode 100644 packages/oft-evm/contracts/interfaces/ITIP20Minter.sol create mode 100644 packages/oft-evm/test/TIP20OFT.t.sol create mode 100644 packages/oft-evm/test/lib/TIP20OFTMockCodec.sol create mode 100644 packages/oft-evm/test/mocks/TIP20OFTMock.sol create mode 100644 packages/oft-evm/test/mocks/TIP20TokenMock.sol diff --git a/packages/oft-evm/contracts/OFTTIP20.sol b/packages/oft-evm/contracts/OFTTIP20.sol new file mode 100644 index 0000000000..2804f9126e --- /dev/null +++ b/packages/oft-evm/contracts/OFTTIP20.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +// External imports +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// Local imports +import { OFTCore } from "./OFTCore.sol"; +import { IEndpointV2Alt } from "./interfaces/IEndpointV2Alt.sol"; +import { ITIP20Minter } from "./interfaces/ITIP20Minter.sol"; + +/** + * @title TIP20OFT + * @notice A variant of the standard OFT that uses TIP20 token's mint and burn mechanisms for cross-chain transfers. + * + * @dev Inherits from OFTCore and provides implementations for _debit and _credit functions using TIP20 token's mint and burn mechanisms. + */ +abstract contract TIP20OFT is OFTCore { + using SafeERC20 for IERC20; + /// @dev The underlying ERC20 token. + address internal immutable innerToken; + address internal immutable nativeToken; + + /// @dev Reverted when the endpoint has no native token (e.g. not an EndpointV2Alt). + error NativeTokenUnavailable(); + + constructor( + address _token, + address _lzEndpoint, + address _delegate + ) OFTCore(IERC20Metadata(_token).decimals(), _lzEndpoint, _delegate) { + innerToken = _token; + nativeToken = IEndpointV2Alt(_lzEndpoint).nativeToken(); + } + + function token() public view returns (address) { + return address(innerToken); + } + + /** + * @dev a transfer is needed so the user needs to approve the tokens first + */ + function approvalRequired() external pure virtual returns (bool) { + return true; + } + + /** + * @dev override needed because TIP20 do not support burning from the user's balance + */ + function _debit( + address _from, + uint256 _amountLD, + uint256 _minAmountLD, + uint32 _dstEid + ) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) { + (amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid); + // Transfer + burn the tokens from the user (instead of burning from the user's balance) + IERC20(innerToken).safeTransferFrom(_from, address(this), amountSentLD); + ITIP20Minter(innerToken).burn(amountSentLD); + } + + function _credit( + address _to, + uint256 _amountLD, + uint32 /* _srcEid */ + ) internal virtual override returns (uint256 amountReceivedLD) { + if (_to == address(0x0)) _to = address(0xdead); // _mint(...) does not support address(0x0) + // Mints the tokens to the recipient + ITIP20Minter(innerToken).mint(_to, _amountLD); + return _amountLD; + } + + /** + * @dev override needed to support Alt endpoints. + * Pays the fee in the native ERC20 token (transfer to endpoint); returns 0 so that + * the OApp does not forward any msg.value to the endpoint (Alt rejects msg.value). + */ + function _payNative(uint256 _nativeFee) internal override returns (uint256 nativeFee) { + if (nativeToken == address(0)) revert NativeTokenUnavailable(); + + // Pay native token fee by sending tokens to the endpoint. + IERC20(nativeToken).safeTransferFrom(msg.sender, address(endpoint), _nativeFee); + + // Return 0 so endpoint.send{ value: 0 } is used (Alt endpoint rejects msg.value). + return 0; + } +} diff --git a/packages/oft-evm/contracts/interfaces/IEndpointV2Alt.sol b/packages/oft-evm/contracts/interfaces/IEndpointV2Alt.sol new file mode 100644 index 0000000000..30730ed64f --- /dev/null +++ b/packages/oft-evm/contracts/interfaces/IEndpointV2Alt.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +/// @notice Minimal interface for EndpointV2Alt to read nativeToken without importing the concrete contract. +interface IEndpointV2Alt { + function nativeToken() external view returns (address); +} diff --git a/packages/oft-evm/contracts/interfaces/ITIP20Minter.sol b/packages/oft-evm/contracts/interfaces/ITIP20Minter.sol new file mode 100644 index 0000000000..00b163547e --- /dev/null +++ b/packages/oft-evm/contracts/interfaces/ITIP20Minter.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +// copied from TIP20 spec: https://github.com/tempoxyz/tempo/blob/3dbee269cabffa58c6942ece1d155783924e8b5e/docs/specs/src/interfaces/ITIP20.sol +interface ITIP20Minter { + function mint(address to, uint256 amount) external; + + function burn(uint256 amount) external; +} diff --git a/packages/oft-evm/test/TIP20OFT.t.sol b/packages/oft-evm/test/TIP20OFT.t.sol new file mode 100644 index 0000000000..9df904ecce --- /dev/null +++ b/packages/oft-evm/test/TIP20OFT.t.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol"; + +import { MessagingFee, MessagingReceipt, OFTReceipt } from "../contracts/OFTCore.sol"; +import { IOFT, SendParam, OFTLimit } from "../contracts/interfaces/IOFT.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import { TIP20TokenMock } from "./mocks/TIP20TokenMock.sol"; +import { TIP20OFTMock } from "./mocks/TIP20OFTMock.sol"; +import { ERC20Mock } from "./mocks/ERC20Mock.sol"; + +import { TestHelperOz5 } from "@layerzerolabs/test-devtools-evm-foundry/contracts/TestHelperOz5.sol"; + +contract TIP20OFTTest is TestHelperOz5 { + using OptionsBuilder for bytes; + + uint32 internal constant A_EID = 1; + uint32 internal constant B_EID = 2; + + string internal constant TOKEN_A_NAME = "TIP20TokenA"; + string internal constant TOKEN_A_SYMBOL = "TIPA"; + string internal constant TOKEN_B_NAME = "TIP20TokenB"; + string internal constant TOKEN_B_SYMBOL = "TIPB"; + + ERC20Mock internal nativeTokenMock; + TIP20TokenMock internal tokenA; + TIP20TokenMock internal tokenB; + TIP20OFTMock internal oftA; + TIP20OFTMock internal oftB; + + address public userA = makeAddr("userA"); + address public userB = makeAddr("userB"); + uint256 public initialBalance = 100 ether; + uint256 public initialNativeBalance = 1000 ether; + + function setUp() public virtual override { + _deal(); + super.setUp(); + + // EndpointV2Alt requires a native token per endpoint + nativeTokenMock = new ERC20Mock("NativeFeeToken", "NAT"); + address[] memory nativeTokens = new address[](2); + nativeTokens[0] = address(nativeTokenMock); + nativeTokens[1] = address(nativeTokenMock); + createEndpoints(2, LibraryType.UltraLightNode, nativeTokens); + + tokenA = new TIP20TokenMock(TOKEN_A_NAME, TOKEN_A_SYMBOL); + tokenB = new TIP20TokenMock(TOKEN_B_NAME, TOKEN_B_SYMBOL); + + oftA = TIP20OFTMock( + _deployOApp( + type(TIP20OFTMock).creationCode, + abi.encode(address(tokenA), address(endpoints[A_EID]), address(this)) + ) + ); + oftB = TIP20OFTMock( + _deployOApp( + type(TIP20OFTMock).creationCode, + abi.encode(address(tokenB), address(endpoints[B_EID]), address(this)) + ) + ); + + address[] memory ofts = new address[](2); + ofts[0] = address(oftA); + ofts[1] = address(oftB); + this.wireOApps(ofts); + + tokenA.mint(userA, initialBalance); + tokenB.mint(userB, initialBalance); + + // Native token for fees (user approves OFT to pay fee) + nativeTokenMock.mint(userA, initialNativeBalance); + nativeTokenMock.mint(userB, initialNativeBalance); + } + + function _deal() internal { + vm.deal(userA, initialNativeBalance); + vm.deal(userB, initialNativeBalance); + } + + function test_constructor() public view { + assertEq(oftA.owner(), address(this)); + assertEq(oftB.owner(), address(this)); + assertEq(oftA.token(), address(tokenA)); + assertEq(oftB.token(), address(tokenB)); + assertEq(tokenA.balanceOf(userA), initialBalance); + assertEq(tokenB.balanceOf(userB), initialBalance); + assertTrue(oftA.approvalRequired()); + assertTrue(oftB.approvalRequired()); + } + + function test_token() public view { + assertEq(oftA.token(), address(tokenA)); + assertEq(oftB.token(), address(tokenB)); + } + + function test_approvalRequired() public view { + assertTrue(oftA.approvalRequired()); + } + + function test_tip20_debit() public { + uint256 amountToSendLD = 1 ether; + uint256 minAmountToCreditLD = 1 ether; + uint32 dstEid = B_EID; + + assertEq(tokenA.balanceOf(userA), initialBalance); + assertEq(tokenA.balanceOf(address(oftA)), 0); + + vm.prank(userA); + tokenA.approve(address(oftA), amountToSendLD); + + vm.prank(userA); + (uint256 amountDebitedLD, uint256 amountToCreditLD) = oftA.debit( + amountToSendLD, + minAmountToCreditLD, + dstEid + ); + + assertEq(amountDebitedLD, amountToSendLD); + assertEq(amountToCreditLD, amountToSendLD); + assertEq(tokenA.balanceOf(userA), initialBalance - amountToSendLD); + assertEq(tokenA.balanceOf(address(oftA)), 0); // OFT burns after receiving + } + + function test_tip20_debit_revertsWithoutApproval() public { + uint256 amountToSendLD = 1 ether; + uint256 minAmountToCreditLD = 1 ether; + uint32 dstEid = B_EID; + + vm.prank(userA); + vm.expectRevert(); + oftA.debit(amountToSendLD, minAmountToCreditLD, dstEid); + } + + function test_tip20_debit_slippageReverts() public { + uint256 amountToSendLD = 1 ether; + uint256 minAmountToCreditLD = 1 ether + 1; + uint32 dstEid = B_EID; + + vm.prank(userA); + tokenA.approve(address(oftA), amountToSendLD); + + vm.prank(userA); + vm.expectRevert(abi.encodeWithSelector(IOFT.SlippageExceeded.selector, amountToSendLD, minAmountToCreditLD)); + oftA.debit(amountToSendLD, minAmountToCreditLD, dstEid); + } + + function test_tip20_credit() public { + uint256 amountToCreditLD = 1 ether; + uint32 srcEid = A_EID; + + assertEq(tokenB.balanceOf(userB), initialBalance); + + vm.prank(address(oftB)); + uint256 amountReceived = oftB.credit(userB, amountToCreditLD, srcEid); + + assertEq(amountReceived, amountToCreditLD); + assertEq(tokenB.balanceOf(userB), initialBalance + amountToCreditLD); + } + + function test_tip20_credit_zeroAddressMintsToDead() public { + uint256 amountToCreditLD = 1 ether; + uint32 srcEid = A_EID; + address dead = address(0xdead); + + uint256 supplyBefore = tokenB.totalSupply(); + + vm.prank(address(oftB)); + uint256 amountReceived = oftB.credit(address(0), amountToCreditLD, srcEid); + + assertEq(amountReceived, amountToCreditLD); + assertEq(tokenB.balanceOf(dead), amountToCreditLD); + assertEq(tokenB.totalSupply(), supplyBefore + amountToCreditLD); + } + + function test_tip20_send() public { + uint256 tokensToSend = 1 ether; + bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200000, 0); + SendParam memory sendParam = SendParam( + B_EID, + addressToBytes32(userB), + tokensToSend, + tokensToSend, + options, + "", + "" + ); + MessagingFee memory fee = oftA.quoteSend(sendParam, false); + + assertEq(tokenA.balanceOf(userA), initialBalance); + assertEq(tokenB.balanceOf(userB), initialBalance); + + vm.startPrank(userA); + tokenA.approve(address(oftA), tokensToSend); + nativeTokenMock.approve(address(oftA), fee.nativeFee); + (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt) = oftA.send( + sendParam, + fee, + payable(address(this)) + ); + vm.stopPrank(); + + assertEq(tokenA.balanceOf(userA), initialBalance - oftReceipt.amountSentLD); + assertEq(msgReceipt.fee.nativeFee, fee.nativeFee); + + // Deliver the packet to oftB (calls lzReceive and credits userB) + verifyPackets(B_EID, addressToBytes32(address(oftB))); + + assertEq(tokenB.balanceOf(userB), initialBalance + oftReceipt.amountReceivedLD); + } + + function test_quoteSend() public view { + SendParam memory sendParam = SendParam( + B_EID, + addressToBytes32(userB), + 1 ether, + 1 ether, + OptionsBuilder.newOptions().addExecutorLzReceiveOption(200000, 0), + "", + "" + ); + MessagingFee memory fee = oftA.quoteSend(sendParam, false); + assertTrue(fee.nativeFee > 0 || fee.lzTokenFee > 0); + } + + function test_quoteOFT() public view { + SendParam memory sendParam = SendParam(B_EID, addressToBytes32(userB), 1 ether, 1 ether, "", "", ""); + (OFTLimit memory oftLimit, , OFTReceipt memory oftReceipt) = oftA.quoteOFT(sendParam); + assertEq(oftLimit.minAmountLD, 0); + assertEq(oftLimit.maxAmountLD, tokenA.totalSupply()); + assertEq(oftReceipt.amountSentLD, 1 ether); + assertEq(oftReceipt.amountReceivedLD, 1 ether); + } +} diff --git a/packages/oft-evm/test/lib/TIP20OFTMockCodec.sol b/packages/oft-evm/test/lib/TIP20OFTMockCodec.sol new file mode 100644 index 0000000000..c396666288 --- /dev/null +++ b/packages/oft-evm/test/lib/TIP20OFTMockCodec.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import { TIP20OFTMock } from "../mocks/TIP20OFTMock.sol"; +import { TIP20OFT } from "../../contracts/OFTTIP20.sol"; + +library TIP20OFTMockCodec { + function asTIP20OFTMock(TIP20OFT _oft) internal pure returns (TIP20OFTMock) { + return TIP20OFTMock(address(_oft)); + } +} diff --git a/packages/oft-evm/test/mocks/TIP20OFTMock.sol b/packages/oft-evm/test/mocks/TIP20OFTMock.sol new file mode 100644 index 0000000000..32168c0af3 --- /dev/null +++ b/packages/oft-evm/test/mocks/TIP20OFTMock.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import { TIP20OFT } from "../../contracts/OFTTIP20.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @title TIP20OFTMock + * @notice Concrete TIP20OFT for testing; exposes _debit and _credit. + */ +contract TIP20OFTMock is TIP20OFT { + constructor( + address _token, + address _lzEndpoint, + address _delegate + ) TIP20OFT(_token, _lzEndpoint, _delegate) Ownable(_delegate) {} + + function debit( + uint256 _amountToSendLD, + uint256 _minAmountToCreditLD, + uint32 _dstEid + ) external returns (uint256 amountDebitedLD, uint256 amountToCreditLD) { + return _debit(msg.sender, _amountToSendLD, _minAmountToCreditLD, _dstEid); + } + + function credit(address _to, uint256 _amountToCreditLD, uint32 _srcEid) external returns (uint256 amountReceivedLD) { + return _credit(_to, _amountToCreditLD, _srcEid); + } +} diff --git a/packages/oft-evm/test/mocks/TIP20TokenMock.sol b/packages/oft-evm/test/mocks/TIP20TokenMock.sol new file mode 100644 index 0000000000..365b97aaca --- /dev/null +++ b/packages/oft-evm/test/mocks/TIP20TokenMock.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ITIP20Minter } from "../../contracts/interfaces/ITIP20Minter.sol"; + +/** + * @title TIP20TokenMock + * @notice ERC20 mock that implements ITIP20Minter for TIP20OFT tests. + * @dev mint(to, amount) mints to an address; burn(amount) burns from msg.sender (caller). + * For testing only; production tokens should enforce access control. + */ +contract TIP20TokenMock is ERC20, ITIP20Minter { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address to, uint256 amount) external override { + _mint(to, amount); + } + + /// @dev Burns amount from msg.sender (e.g. the OFT contract after it receives tokens). + function burn(uint256 amount) external override { + _burn(msg.sender, amount); + } +} From c82c7468a348b6b631440f9309fdf498bd6e0508 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 18 Feb 2026 21:58:39 +0100 Subject: [PATCH 2/4] feat: move contracts to oft-alt folder --- .../contracts/OFTTIP20.sol | 38 +++++-------------- .../contracts/interfaces/ITIP20Minter.sol | 0 .../test/TIP20OFT.t.sol | 13 +++---- .../test/mocks/TIP20OFTMock.sol | 0 .../test/mocks/TIP20TokenMock.sol | 0 .../contracts/interfaces/IEndpointV2Alt.sol | 7 ---- .../oft-evm/test/lib/TIP20OFTMockCodec.sol | 11 ------ 7 files changed, 14 insertions(+), 55 deletions(-) rename packages/{oft-evm => oft-alt-evm}/contracts/OFTTIP20.sol (54%) rename packages/{oft-evm => oft-alt-evm}/contracts/interfaces/ITIP20Minter.sol (100%) rename packages/{oft-evm => oft-alt-evm}/test/TIP20OFT.t.sol (93%) rename packages/{oft-evm => oft-alt-evm}/test/mocks/TIP20OFTMock.sol (100%) rename packages/{oft-evm => oft-alt-evm}/test/mocks/TIP20TokenMock.sol (100%) delete mode 100644 packages/oft-evm/contracts/interfaces/IEndpointV2Alt.sol delete mode 100644 packages/oft-evm/test/lib/TIP20OFTMockCodec.sol diff --git a/packages/oft-evm/contracts/OFTTIP20.sol b/packages/oft-alt-evm/contracts/OFTTIP20.sol similarity index 54% rename from packages/oft-evm/contracts/OFTTIP20.sol rename to packages/oft-alt-evm/contracts/OFTTIP20.sol index 2804f9126e..1fd07b2997 100644 --- a/packages/oft-evm/contracts/OFTTIP20.sol +++ b/packages/oft-alt-evm/contracts/OFTTIP20.sol @@ -6,32 +6,28 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; // Local imports -import { OFTCore } from "./OFTCore.sol"; -import { IEndpointV2Alt } from "./interfaces/IEndpointV2Alt.sol"; +import { OFTAltCore } from "./OFTAltCore.sol"; import { ITIP20Minter } from "./interfaces/ITIP20Minter.sol"; /** * @title TIP20OFT * @notice A variant of the standard OFT that uses TIP20 token's mint and burn mechanisms for cross-chain transfers. * - * @dev Inherits from OFTCore and provides implementations for _debit and _credit functions using TIP20 token's mint and burn mechanisms. + * @dev Inherits from OFTAltCore and provides implementations for _debit and _credit using TIP20 token's mint and burn. + * Native fee payment is already handled by OAppSenderAlt (pay in ERC20 native token); no msg.value. */ -abstract contract TIP20OFT is OFTCore { +abstract contract TIP20OFT is OFTAltCore { using SafeERC20 for IERC20; + /// @dev The underlying ERC20 token. address internal immutable innerToken; - address internal immutable nativeToken; - - /// @dev Reverted when the endpoint has no native token (e.g. not an EndpointV2Alt). - error NativeTokenUnavailable(); constructor( address _token, address _lzEndpoint, address _delegate - ) OFTCore(IERC20Metadata(_token).decimals(), _lzEndpoint, _delegate) { + ) OFTAltCore(IERC20Metadata(_token).decimals(), _lzEndpoint, _delegate) { innerToken = _token; - nativeToken = IEndpointV2Alt(_lzEndpoint).nativeToken(); } function token() public view returns (address) { @@ -39,14 +35,14 @@ abstract contract TIP20OFT is OFTCore { } /** - * @dev a transfer is needed so the user needs to approve the tokens first + * @dev A transfer is needed so the user needs to approve the tokens first. */ function approvalRequired() external pure virtual returns (bool) { return true; } /** - * @dev override needed because TIP20 do not support burning from the user's balance + * @dev Override needed because TIP20 does not support burning from the user's balance. */ function _debit( address _from, @@ -55,7 +51,7 @@ abstract contract TIP20OFT is OFTCore { uint32 _dstEid ) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) { (amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid); - // Transfer + burn the tokens from the user (instead of burning from the user's balance) + // Transfer + burn the tokens from the user (instead of burning from the user's balance). IERC20(innerToken).safeTransferFrom(_from, address(this), amountSentLD); ITIP20Minter(innerToken).burn(amountSentLD); } @@ -66,23 +62,7 @@ abstract contract TIP20OFT is OFTCore { uint32 /* _srcEid */ ) internal virtual override returns (uint256 amountReceivedLD) { if (_to == address(0x0)) _to = address(0xdead); // _mint(...) does not support address(0x0) - // Mints the tokens to the recipient ITIP20Minter(innerToken).mint(_to, _amountLD); return _amountLD; } - - /** - * @dev override needed to support Alt endpoints. - * Pays the fee in the native ERC20 token (transfer to endpoint); returns 0 so that - * the OApp does not forward any msg.value to the endpoint (Alt rejects msg.value). - */ - function _payNative(uint256 _nativeFee) internal override returns (uint256 nativeFee) { - if (nativeToken == address(0)) revert NativeTokenUnavailable(); - - // Pay native token fee by sending tokens to the endpoint. - IERC20(nativeToken).safeTransferFrom(msg.sender, address(endpoint), _nativeFee); - - // Return 0 so endpoint.send{ value: 0 } is used (Alt endpoint rejects msg.value). - return 0; - } } diff --git a/packages/oft-evm/contracts/interfaces/ITIP20Minter.sol b/packages/oft-alt-evm/contracts/interfaces/ITIP20Minter.sol similarity index 100% rename from packages/oft-evm/contracts/interfaces/ITIP20Minter.sol rename to packages/oft-alt-evm/contracts/interfaces/ITIP20Minter.sol diff --git a/packages/oft-evm/test/TIP20OFT.t.sol b/packages/oft-alt-evm/test/TIP20OFT.t.sol similarity index 93% rename from packages/oft-evm/test/TIP20OFT.t.sol rename to packages/oft-alt-evm/test/TIP20OFT.t.sol index 9df904ecce..823bdf7c7d 100644 --- a/packages/oft-evm/test/TIP20OFT.t.sol +++ b/packages/oft-alt-evm/test/TIP20OFT.t.sol @@ -3,13 +3,13 @@ pragma solidity ^0.8.22; import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol"; -import { MessagingFee, MessagingReceipt, OFTReceipt } from "../contracts/OFTCore.sol"; -import { IOFT, SendParam, OFTLimit } from "../contracts/interfaces/IOFT.sol"; +import { MessagingFee, MessagingReceipt, OFTReceipt, OFTAltCore } from "../contracts/OFTAltCore.sol"; +import { IOFT, SendParam, OFTLimit } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { TIP20TokenMock } from "./mocks/TIP20TokenMock.sol"; import { TIP20OFTMock } from "./mocks/TIP20OFTMock.sol"; -import { ERC20Mock } from "./mocks/ERC20Mock.sol"; +import { ERC20Mock } from "@layerzerolabs/oft-evm/test/mocks/ERC20Mock.sol"; import { TestHelperOz5 } from "@layerzerolabs/test-devtools-evm-foundry/contracts/TestHelperOz5.sol"; @@ -39,7 +39,6 @@ contract TIP20OFTTest is TestHelperOz5 { _deal(); super.setUp(); - // EndpointV2Alt requires a native token per endpoint nativeTokenMock = new ERC20Mock("NativeFeeToken", "NAT"); address[] memory nativeTokens = new address[](2); nativeTokens[0] = address(nativeTokenMock); @@ -70,7 +69,6 @@ contract TIP20OFTTest is TestHelperOz5 { tokenA.mint(userA, initialBalance); tokenB.mint(userB, initialBalance); - // Native token for fees (user approves OFT to pay fee) nativeTokenMock.mint(userA, initialNativeBalance); nativeTokenMock.mint(userB, initialNativeBalance); } @@ -121,7 +119,7 @@ contract TIP20OFTTest is TestHelperOz5 { assertEq(amountDebitedLD, amountToSendLD); assertEq(amountToCreditLD, amountToSendLD); assertEq(tokenA.balanceOf(userA), initialBalance - amountToSendLD); - assertEq(tokenA.balanceOf(address(oftA)), 0); // OFT burns after receiving + assertEq(tokenA.balanceOf(address(oftA)), 0); } function test_tip20_debit_revertsWithoutApproval() public { @@ -205,7 +203,6 @@ contract TIP20OFTTest is TestHelperOz5 { assertEq(tokenA.balanceOf(userA), initialBalance - oftReceipt.amountSentLD); assertEq(msgReceipt.fee.nativeFee, fee.nativeFee); - // Deliver the packet to oftB (calls lzReceive and credits userB) verifyPackets(B_EID, addressToBytes32(address(oftB))); assertEq(tokenB.balanceOf(userB), initialBalance + oftReceipt.amountReceivedLD); @@ -229,7 +226,7 @@ contract TIP20OFTTest is TestHelperOz5 { SendParam memory sendParam = SendParam(B_EID, addressToBytes32(userB), 1 ether, 1 ether, "", "", ""); (OFTLimit memory oftLimit, , OFTReceipt memory oftReceipt) = oftA.quoteOFT(sendParam); assertEq(oftLimit.minAmountLD, 0); - assertEq(oftLimit.maxAmountLD, tokenA.totalSupply()); + assertEq(oftLimit.maxAmountLD, type(uint64).max); assertEq(oftReceipt.amountSentLD, 1 ether); assertEq(oftReceipt.amountReceivedLD, 1 ether); } diff --git a/packages/oft-evm/test/mocks/TIP20OFTMock.sol b/packages/oft-alt-evm/test/mocks/TIP20OFTMock.sol similarity index 100% rename from packages/oft-evm/test/mocks/TIP20OFTMock.sol rename to packages/oft-alt-evm/test/mocks/TIP20OFTMock.sol diff --git a/packages/oft-evm/test/mocks/TIP20TokenMock.sol b/packages/oft-alt-evm/test/mocks/TIP20TokenMock.sol similarity index 100% rename from packages/oft-evm/test/mocks/TIP20TokenMock.sol rename to packages/oft-alt-evm/test/mocks/TIP20TokenMock.sol diff --git a/packages/oft-evm/contracts/interfaces/IEndpointV2Alt.sol b/packages/oft-evm/contracts/interfaces/IEndpointV2Alt.sol deleted file mode 100644 index 30730ed64f..0000000000 --- a/packages/oft-evm/contracts/interfaces/IEndpointV2Alt.sol +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; - -/// @notice Minimal interface for EndpointV2Alt to read nativeToken without importing the concrete contract. -interface IEndpointV2Alt { - function nativeToken() external view returns (address); -} diff --git a/packages/oft-evm/test/lib/TIP20OFTMockCodec.sol b/packages/oft-evm/test/lib/TIP20OFTMockCodec.sol deleted file mode 100644 index c396666288..0000000000 --- a/packages/oft-evm/test/lib/TIP20OFTMockCodec.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.22; - -import { TIP20OFTMock } from "../mocks/TIP20OFTMock.sol"; -import { TIP20OFT } from "../../contracts/OFTTIP20.sol"; - -library TIP20OFTMockCodec { - function asTIP20OFTMock(TIP20OFT _oft) internal pure returns (TIP20OFTMock) { - return TIP20OFTMock(address(_oft)); - } -} From d0d7da92ecb252ac2c9219b395b0866974671d89 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 18 Feb 2026 21:59:42 +0100 Subject: [PATCH 3/4] fix rename file --- packages/oft-alt-evm/contracts/{OFTTIP20.sol => TIP20OFT.sol} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/oft-alt-evm/contracts/{OFTTIP20.sol => TIP20OFT.sol} (100%) diff --git a/packages/oft-alt-evm/contracts/OFTTIP20.sol b/packages/oft-alt-evm/contracts/TIP20OFT.sol similarity index 100% rename from packages/oft-alt-evm/contracts/OFTTIP20.sol rename to packages/oft-alt-evm/contracts/TIP20OFT.sol From e6440147666f13fc0fe24f4f7a3dede9aec514c6 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 18 Feb 2026 22:00:28 +0100 Subject: [PATCH 4/4] feat: add changeset --- .changeset/tender-berries-agree.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tender-berries-agree.md diff --git a/.changeset/tender-berries-agree.md b/.changeset/tender-berries-agree.md new file mode 100644 index 0000000000..03910cfd77 --- /dev/null +++ b/.changeset/tender-berries-agree.md @@ -0,0 +1,5 @@ +--- +"@layerzerolabs/oft-alt-evm": patch +--- + +Added oft tip 20 contract