Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tender-berries-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@layerzerolabs/oft-alt-evm": patch
---

Added oft tip 20 contract
68 changes: 68 additions & 0 deletions packages/oft-alt-evm/contracts/TIP20OFT.sol
Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Larger ask, can we reframe this as a TIP20MintBurnOFTAltAdapter? Naming is gross, but consistent.

I don't see the need for MintBurn in the name if the mint burn is for the underlying Tip20, and it is not following the patter in MintBurnOFTAdapter when a _minterBurner is 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 isMintBurnOFTAdapter is 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.

Copy link
Contributor

@St0rmBr3w St0rmBr3w Feb 18, 2026

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).

Copy link
Contributor Author

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/unlock and mint/burn mechanisms. 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 use lock/unlock seems potentially dangerous for me

Copy link
Contributor

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 MintBurnOFTAdapter in oft-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.

Copy link
Contributor

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.

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;
}
}
9 changes: 9 additions & 0 deletions packages/oft-alt-evm/contracts/interfaces/ITIP20Minter.sol
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;
}
233 changes: 233 additions & 0 deletions packages/oft-alt-evm/test/TIP20OFT.t.sol
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);
}
}
29 changes: 29 additions & 0 deletions packages/oft-alt-evm/test/mocks/TIP20OFTMock.sol
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);
}
}
24 changes: 24 additions & 0 deletions packages/oft-alt-evm/test/mocks/TIP20TokenMock.sol
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);
}
}
Loading