Skip to main content
Version: Endpoint V2 Docs

OFT Patterns and Extensions

The Omnichain Fungible Token (OFT) Standard can be extended to support several different use cases, similar to the ERC20 token standard.

In addition to the OApp Design Patterns and Extensions, the following examples demonstrate how to modify your OFT contract for specific use cases.

Message PatternDescription
Composed OFTa composed call made after the OFT delivers the token transfer
OFT Alta variant of the OFT standard that supports EndpointV2Alt for paying in an alternative ERC20 token

Composed OFT

A composed message refers to an OApp that invokes the LayerZero Endpoint method sendCompose to deliver a composed call to another contract on the destination chain via lzCompose.

Because OFT inherits the base OApp implementation, you can also send composed messages within your OFT receive logic.

Composed Light Composed Dark

If you are not familiar with how OApp Composing works, review that section first before continuing here.

Composing an OFT

The OFT Standard comes pre-packaged with methods for delivering composed calls to the destination OFT contract for handling.

  1. Source OFT: The Source OFT specifies in the send call a composed message in bytes for delivering to. You can think of this the same as how _lzSend sends arbitrary bytes to a destination, which the destination contract uses in the _lzReceive business logic.

  2. Destination OFT(s): When the send call is received by the destination OFT, the internal _lzReceive function in OFTCore.sol handles the delivery of tokens along with the composed call.

  3. Composed Receiver(s): the contract interface implementing business logic to handle receiving a composed message via lzCompose.

Sending Token

When sending a token from source to destination, the caller has the option to specify an additional composeMsg in bytes.

/**
* @dev Struct representing token parameters for the OFT send() operation.
*/
struct SendParam {
uint32 dstEid; // Destination endpoint ID.
bytes32 to; // Recipient address.
uint256 amountLD; // Amount to send in local decimals.
uint256 minAmountLD; // Minimum amount to send in local decimals.
bytes extraOptions; // Additional options supplied by the caller to be used in the LayerZero message.
bytes composeMsg; // The composed message for the send() operation.
bytes oftCmd; // The OFT command to be executed, unused in default OFT implementations.
}

function send(
SendParam calldata _sendParam,
MessagingFee calldata _fee,
address _refundAddress
) external payable virtual returns (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt) {}

Depending on your implementation, this composed message field can be used to pass any arbitrary information as bytes along with your token to the destination address.

Composed Message Execution Options

You will need to pass both an lzReceiveOption and lzComposeOption as either your enforced or extra options for this call to succeed.

You can decide both the _gas and msg.value that should be used for the composed call(s), depending on the type and quantity of messages you intend to send.

Your configured Executor will use the _options provided in the original _lzSend call to determine the gas limit and amount of msg.value to include per message _index:

// addExecutorLzComposeOption(uint16 _index, uint128 _gas, uint128 _value)
Options.newOptions()
.addExecutorLzReceiveOption(50000, 0)
.addExecutorLzComposeOption(0, 30000, 0)
.addExecutorLzComposeOption(1, 30000, 0);

It's important to remember that gas values may vary depending on the destination chain. For example, all new Ethereum transactions cost 21000 wei, but other chains may have lower or higher opcode costs, or entirely different gas mechanisms.

You can read more about generating _options and the role of _index in Message Execution Options.

Sending Compose

By default, the destination OFT's _lzReceive method will check if the message is composed, and then deliver those arbitrary bytes to the specified toAddress:

// @dev Internal function to handle the receive on the LayerZero endpoint.
if (_message.isComposed()) {
// @dev Proprietary composeMsg format for the OFT.
bytes memory composeMsg = OFTComposeMsgCodec.encode(
_origin.nonce,
_origin.srcEid,
amountReceivedLD,
_message.composeMsg()
);

// @dev Stores the lzCompose payload that will be executed in a separate tx.
// Standardizes functionality for executing arbitrary contract invocation on some non-evm chains.
// @dev The off-chain executor will listen and process the msg based on the src-chain-callers compose options passed.
// @dev The index is used when a OApp needs to compose multiple msgs on lzReceive.
// For default OFT implementation there is only 1 compose msg per lzReceive, thus its always 0.
endpoint.sendCompose(toAddress, _guid, 0 /* the index of the composed message*/, composeMsg);
}

As shown in the sendCompose comments, the base OFT implementation only allows for 1 composed message per lzReceive call.

To add additional composed calls, you will need to override the _lzReceive method and add custom composed logic.

Receiving Compose

The receiving address of the cross-chain token transfer will need to implement custom business logic to handle the composed message, for example, consider this mock contract that swaps an inbound OFT for an ERC20:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol";
import { IOAppComposer } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppComposer.sol";
import { OFTComposeMsgCodec } from "@layerzerolabs/oft-evm/contracts/libs/OFTComposeMsgCodec.sol";

/// @title SwapMock Contract
/// @dev This contract mocks an ERC20 token swap in response to an OFT being received (lzReceive) on the destination chain.
/// @notice The contract is designed to interact with LayerZero's Omnichain Fungible Token (OFT) Standard,
/// allowing it to respond to cross-chain OFT mint events with a token swap action.
contract SwapMock is IOAppComposer {
using SafeERC20 for IERC20;

IERC20 public erc20;
address public immutable endpoint;
address public immutable oApp;

/// @notice Emitted when a token swap is executed.
/// @param user The address of the user who receives the swapped tokens.
/// @param tokenOut The address of the ERC20 token being swapped.
/// @param amount The amount of tokens swapped.
event Swapped(address indexed user, address tokenOut, uint256 amount);

/// @notice Constructs the SwapMock contract.
/// @dev Initializes the contract.
/// @param _erc20 The address of the ERC20 token that will be used in swaps.
/// @param _endpoint LayerZero Endpoint address
/// @param _oApp The address of the OApp that is sending the composed message.
constructor(address _erc20, address _endpoint, address _oApp) {
erc20 = IERC20(_erc20);
endpoint = _endpoint;
oApp = _oApp;
}

/// @notice Handles incoming composed messages from LayerZero.
/// @dev Decodes the message payload to perform a token swap.
/// This method expects the encoded compose message to contain the swap amount and recipient address.
/// @param _oApp The address of the originating OApp.
/// @param /*_guid*/ The globally unique identifier of the message (unused in this mock).
/// @param _message The encoded message content in the format of the OFTComposeMsgCodec.
/// @param /*Executor*/ Executor address (unused in this mock).
/// @param /*Executor Data*/ Additional data for checking for a specific executor (unused in this mock).
function lzCompose(
address _oApp,
bytes32 /*_guid*/,
bytes calldata _message,
address /*Executor*/,
bytes calldata /*Executor Data*/
) external payable override {
require(_oApp == oApp, "!oApp");
require(msg.sender == endpoint, "!endpoint");
// Extract the composed message from the delivered message using the MsgCodec
address _receiver = abi.decode(OFTComposeMsgCodec.composeMsg(_message), (address));
uint256 _amountLD = OFTComposeMsgCodec.amountLD(_message);
// Execute the token swap by transferring the specified amount to the receiver
erc20.safeTransfer(_receiver, _amountLD);

// Emit an event to log the token swap details
emit Swapped(_receiver, address(erc20), _amountLD);
}
}

You will need to use the OFTComposeMsgCodec to extract the composeMsg and _amountLD from the overall message, before decoding it.

tip

The above example enforces that the _amountLD was deposited to this contract!

The OFT Standard will only credit tokens and call sendCompose to the _toAddress provided on the source chain:

// @dev Credit the amountLD to the recipient and return the ACTUAL amount the recipient received in local decimals
uint256 amountReceivedLD = _credit(toAddress, _toLD(_message.amountSD()), _origin.srcEid);

endpoint.sendCompose(toAddress, _guid, 0 /* the index of the composed message*/, composeMsg);

Further Reading

OFT Alt

When deploying OApps, you might encounter scenarios where the native gas token cannot be used to pay the LayerZero Endpoint to send a message.

These Endpoints have been deployed using the EndpointV2Alt.sol contract, so that they can use an alternative ERC20 token on the same chain to pay for cross-chain messages. Because these Endpoints do not use the native gas token, some changes must be made to your OApp contracts (including OFT).

For example, the OFTAlt.sol demonstrates this implementation fully, which you can reference when modifying your other OApp-based contracts:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;

import { OFTAlt } from "../OFTAlt.sol";

contract MyOFT is OFTAlt {
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) OFTAlt(_name, _symbol, _lzEndpoint, _delegate) {
// constructor logic ...
}
}

Contract Changes

At a high level, only a few changes to your OApp are needed to interact with the EndpointV2Alt.sol contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";

import { SafeERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

import { MessagingParams } from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroEndpointV2.sol";

import { MessagingFee, MessagingReceipt } from "../interfaces/IOFT.sol";
import { OFT } from "../OFT.sol";

contract OFTAlt is OFT {
using SafeERC20 for IERC20;

error LzAltTokenUnavailable();

constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {}

/**
* @dev Internal function to interact with the LayerZero EndpointV2.send() for sending a message.
* @param _dstEid The destination endpoint ID.
* @param _message The message payload.
* @param _options Additional options for the message.
* @param _fee The calculated LayerZero fee for the message.
* - nativeFee: The native fee.
* - lzTokenFee: The lzToken fee.
* @param _refundAddress The address to receive any excess fee values sent to the endpoint.
* @return receipt The receipt for the sent message.
* - guid: The unique identifier for the sent message.
* - nonce: The nonce of the sent message.
* - fee: The LayerZero fee incurred for the message.
*/
function _lzSend(
uint32 _dstEid,
bytes memory _message,
bytes memory _options,
MessagingFee memory _fee,
address _refundAddress
) internal virtual override returns (MessagingReceipt memory receipt) {
// @dev Push corresponding fees to the endpoint, any excess is sent back to the _refundAddress from the endpoint.
_payNative(_fee.nativeFee);
if (_fee.lzTokenFee > 0) _payLzToken(_fee.lzTokenFee);

return
// solhint-disable-next-line check-send-result
endpoint.send(
MessagingParams(_dstEid, _getPeerOrRevert(_dstEid), _message, _options, _fee.lzTokenFee > 0),
_refundAddress
);
}

/**
* @dev Internal function to pay the alt token fee associated with the message.
* @param _nativeFee The alt token fee to be paid.
*
* @dev If the OApp needs to initiate MULTIPLE LayerZero messages in a single transaction,
* this will need to be overridden because alt token would contain multiple lzFees.
*/
function _payNative(uint256 _nativeFee) internal virtual override returns (uint256 nativeFee) {
address nativeErc20 = endpoint.nativeToken();
if (nativeErc20 == address(0)) revert LzAltTokenUnavailable();

// Pay Alt token fee by sending tokens to the endpoint.
IERC20(nativeErc20).safeTransferFrom(msg.sender, address(endpoint), _nativeFee);
}
}

Pass OpenZeppelin Ownable

constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {}

Make sure to pass OpenZeppelin's Ownable modifier to the constructor. The access control has already been applied, but must be explicitly passed in the constructor to compile successfully.

Using SafeERC20

using SafeERC20 for IERC20;

You should include the SafeERC20 library for safely interacting with ERC20 tokens. This is crucial for ensuring that token transfers handle potential errors like reverts or exceptions.

Error Handling

error LzAltTokenUnavailable();

A custom error LzAltTokenUnavailable which is used to handle cases where the native ERC20 token for fee payment is not set in the EndpointV2Alt contract.

Override _payNative

/**
* @dev Internal function to pay the alt token fee associated with the message.
* @param _nativeFee The alt token fee to be paid.
*
* @dev If the OApp needs to initiate MULTIPLE LayerZero messages in a single transaction,
* this will need to be overridden because alt token would contain multiple lzFees.
*/
function _payNative(uint256 _nativeFee) internal virtual override returns (uint256 nativeFee) {
address nativeErc20 = endpoint.nativeToken();
if (nativeErc20 == address(0)) revert LzAltTokenUnavailable();

// Pay Alt token fee by sending tokens to the endpoint.
IERC20(nativeErc20).safeTransferFrom(msg.sender, address(endpoint), _nativeFee);
}

You should override the _payNative function to handle paying using an ERC20 token. This function checks if the ERC20 token address is set (nativeErc20), reverts if not, and performs a safeTransferFrom to transfer the fee from the sender to the endpoint.

This ensures that the contract can handle fees in the specified ERC20 token by the EndpointV2Alt.

Override _lzSend

/**
* @dev Internal function to interact with the LayerZero EndpointV2.send() for sending a message.
* @param _dstEid The destination endpoint ID.
* @param _message The message payload.
* @param _options Additional options for the message.
* @param _fee The calculated LayerZero fee for the message.
* - nativeFee: The native fee.
* - lzTokenFee: The lzToken fee.
* @param _refundAddress The address to receive any excess fee values sent to the endpoint.
* @return receipt The receipt for the sent message.
* - guid: The unique identifier for the sent message.
* - nonce: The nonce of the sent message.
* - fee: The LayerZero fee incurred for the message.
*/
function _lzSend(
uint32 _dstEid,
bytes memory _message,
bytes memory _options,
MessagingFee memory _fee,
address _refundAddress
) internal virtual override returns (MessagingReceipt memory receipt) {
// @dev Push corresponding fees to the endpoint, any excess is sent back to the _refundAddress from the endpoint.
_payNative(_fee.nativeFee);
if (_fee.lzTokenFee > 0) _payLzToken(_fee.lzTokenFee);

return
// solhint-disable-next-line check-send-result
endpoint.send(
MessagingParams(_dstEid, _getPeerOrRevert(_dstEid), _message, _options, _fee.lzTokenFee > 0),
_refundAddress
);
}

To apply the changes made in _payNative, you should also override _lzSend to handle the ERC20 token fee.

info

Because _lzSend now uses an ERC20 token as payment, you must now approve the OFT as a spender of your ERC20 token.