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 diff --git a/packages/oft-alt-evm/contracts/TIP20OFT.sol b/packages/oft-alt-evm/contracts/TIP20OFT.sol new file mode 100644 index 0000000000..1fd07b2997 --- /dev/null +++ b/packages/oft-alt-evm/contracts/TIP20OFT.sol @@ -0,0 +1,68 @@ +// 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 { 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 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 OFTAltCore { + using SafeERC20 for IERC20; + + /// @dev The underlying ERC20 token. + address internal immutable innerToken; + + constructor( + address _token, + address _lzEndpoint, + address _delegate + ) OFTAltCore(IERC20Metadata(_token).decimals(), _lzEndpoint, _delegate) { + innerToken = _token; + } + + 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 does 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) + ITIP20Minter(innerToken).mint(_to, _amountLD); + return _amountLD; + } +} diff --git a/packages/oft-alt-evm/contracts/interfaces/ITIP20Minter.sol b/packages/oft-alt-evm/contracts/interfaces/ITIP20Minter.sol new file mode 100644 index 0000000000..00b163547e --- /dev/null +++ b/packages/oft-alt-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-alt-evm/test/TIP20OFT.t.sol b/packages/oft-alt-evm/test/TIP20OFT.t.sol new file mode 100644 index 0000000000..823bdf7c7d --- /dev/null +++ b/packages/oft-alt-evm/test/TIP20OFT.t.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.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 "@layerzerolabs/oft-evm/test/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(); + + 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); + + 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); + } + + 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); + + 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, type(uint64).max); + assertEq(oftReceipt.amountSentLD, 1 ether); + assertEq(oftReceipt.amountReceivedLD, 1 ether); + } +} diff --git a/packages/oft-alt-evm/test/mocks/TIP20OFTMock.sol b/packages/oft-alt-evm/test/mocks/TIP20OFTMock.sol new file mode 100644 index 0000000000..32168c0af3 --- /dev/null +++ b/packages/oft-alt-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-alt-evm/test/mocks/TIP20TokenMock.sol b/packages/oft-alt-evm/test/mocks/TIP20TokenMock.sol new file mode 100644 index 0000000000..365b97aaca --- /dev/null +++ b/packages/oft-alt-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); + } +}