-
Notifications
You must be signed in to change notification settings - Fork 269
Add oft tip 20 implementation and tests #1933
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
clauBv23
wants to merge
4
commits into
main
Choose a base branch
from
oft-tip-20
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@layerzerolabs/oft-alt-evm": patch | ||
| --- | ||
|
|
||
| Added oft tip 20 contract |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we rename file TIP20OFT to be consistent?
Larger ask, can we reframe this as a TIP20MintBurnOFTAltAdapter? Naming is gross, but consistent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see the need for
MintBurnin the name if the mint burn is for the underlying Tip20, and it is not following the patter inMintBurnOFTAdapterwhen a_minterBurneris defined.I don't think it should be an adapter either. Adapters are used for existent ERC20s that can't mint, so they lock tokens (like a pool). This is not the case, it is just an oft that instead of having the ERC20 embedded has an underlying tip20 token.
Why is
MintBurnOFTAdapteris called adapter? I think this is misleading; adapters should be unique per mesh, because they control all mesh liquidity.Checking the docs I see it says
Unlike the standard OFTAdapter which locks/unlocks tokens, this adapter burns tokens on the source chain and mints them on the destination chain. (1)
But I see it more like and OFT that, unlike the standard OFT, which has the ERC20 token embedded, it has an underlying ERC20 where the OFT is the minter._
All StargateOFTs are like that since we use Circle implementation for USDC and Tether implementation for USDT.
I can rename it as you suggested, to be aligned with the documentation, but I find it misleading.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adapter actually is just naming convention for when the token itself is not the OFT contract. It doesn't specify the debit or credit mechanism, and this was an oversight of the original adapter code (also why we're renaming to explicitly denote lock/unlock, burn/mint in the future).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My point is that, from a Stargate perspective, adapters are the pool-like contracts; the ones that hold the mesh liquidity. OFTs do not hold shared liquidity; they simply manage the token itself, whether embedded or underlying.
This was also my understanding when I went through the LayerZero documentation some time ago. It made sense to me, since only a single adapter should be allowed in a mesh. Otherwise, liquidity would be fragmented across multiple chains, which could result in tokens not flowing correctly.
Under this interpretation, an OFT mesh would be of a set of OFT contracts deployed across chains, with at most one Adapter responsible for managing the shared liquidity.
Adding Mint/Burn to contract name makes sense, but ultimately all OFTs call
OFT::token::mint/burn. The only difference is that, in some cases, the token being minted or burned belongs to another contract address rather than the OFT itself.The real distinction, in my view, is between
lock/unlockandmint/burnmechanisms. Which, from my pov was what differentiated Adapters from OFTs. This is a significant difference that needs to be clearly defined and carefully considered, due to the liquidity fragmentation risks mentioned earlier. Defining adapters as OFTs that uselock/unlockseems potentially dangerous for meThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adapters adapt an existing token to the OFT interface. Whether it locks/unlocks or mints/burns doesn't matter for naming. We already ship
MintBurnOFTAdapterinoft-evm/contracts/which burns on debit and mints on credit. No pool, no locking.For now let's just focus on consistency with what's already published in the package. We can sort out the naming with the console team when we work on the new OFT package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The risk is in double spend of escrowed funds so OFTs that "lock" tokens in escrow are the risk and the main thing to communicate to customers.