Skip to main content
Version: Endpoint V2

LayerZero V2 OFT Quickstart

The Omnichain Fungible Token (OFT) Standard allows fungible tokens to be transferred across multiple blockchains without asset wrapping or middlechains.

This standard works by burning tokens on the source chain whenever an omnichain transfer is initiated, sending a message via the protocol and delivering a function call to the destination contract to mint the same number of tokens burned, creating a unified supply across all networks LayerZero supports.

OFT Example OFT Example

Using this design pattern, LayerZero can extend any fungible token to interoperate with other chains. The most widely used of these standards is OFT.sol, an extension of the OApp Contract Standard and the ERC20 Token Standard.

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

import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IOFT, OFTCore } from "./OFTCore.sol";

/**
* @title OFT Contract
* @dev OFT is an ERC-20 token that extends the functionality of the OFTCore contract.
*/
abstract contract OFT is OFTCore, ERC20 {
/**
* @dev Constructor for the OFT contract.
* @param _name The name of the OFT.
* @param _symbol The symbol of the OFT.
* @param _lzEndpoint The LayerZero endpoint address.
* @param _delegate The delegate capable of making OApp configurations inside of the endpoint.
*/
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) ERC20(_name, _symbol) OFTCore(decimals(), _lzEndpoint, _delegate) {}

/**
* @notice Retrieves interfaceID and the version of the OFT.
* @return interfaceId The interface ID.
* @return version The version.
*
* @dev interfaceId: This specific interface ID is '0x02e49c2c'.
* @dev version: Indicates a cross-chain compatible msg encoding with other OFTs.
* @dev If a new feature is added to the OFT cross-chain msg encoding, the version will be incremented.
* ie. localOFT version(x,1) CAN send messages to remoteOFT version(x,1)
*/
function oftVersion() external pure virtual returns (bytes4 interfaceId, uint64 version) {
return (type(IOFT).interfaceId, 1);
}

/**
* @dev Retrieves the address of the underlying ERC20 implementation.
* @return The address of the OFT token.
*
* @dev In the case of OFT, address(this) and erc20 are the same contract.
*/
function token() external view returns (address) {
return address(this);
}

/**
* @notice Indicates whether the OFT contract requires approval of the 'token()' to send.
* @return requiresApproval Needs approval of the underlying token implementation.
*
* @dev In the case of OFT where the contract IS the token, approval is NOT required.
*/
function approvalRequired() external pure virtual returns (bool) {
return false;
}

/**
* @dev Burns tokens from the sender's specified balance.
* @param _amountLD The amount of tokens to send in local decimals.
* @param _minAmountLD The minimum amount to send in local decimals.
* @param _dstEid The destination chain ID.
* @return amountSentLD The amount sent in local decimals.
* @return amountReceivedLD The amount received in local decimals on the remote.
*/
function _debit(
uint256 _amountLD,
uint256 _minAmountLD,
uint32 _dstEid
) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) {
(amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid);

// @dev In NON-default OFT, amountSentLD could be 100, with a 10% fee, the amountReceivedLD amount is 90,
// therefore amountSentLD CAN differ from amountReceivedLD.

// @dev Default OFT burns on src.
_burn(msg.sender, amountSentLD);
}

/**
* @dev Credits tokens to the specified address.
* @param _to The address to credit the tokens to.
* @param _amountLD The amount of tokens to credit in local decimals.
* @dev _srcEid The source chain ID.
* @return amountReceivedLD The amount of tokens ACTUALLY received in local decimals.
*/
function _credit(
address _to,
uint256 _amountLD,
uint32 /*_srcEid*/
) internal virtual override returns (uint256 amountReceivedLD) {
// @dev Default OFT mints on dst.
_mint(_to, _amountLD);
// @dev In the case of NON-default OFT, the _amountLD MIGHT not be == amountReceivedLD.
return _amountLD;
}
}
note

If you prefer reading the contract code, see the OFT contract in the Protocol Github.


tip

Looking to use an already deployed ERC20 token as an OFT? Use the OFT Adapter extension.

Installation

To start using LayerZero contracts, you can install the OApp npm package to an existing project:

npm install @layerzerolabs/lz-evm-oapp-v2
info

LayerZero contracts work with both OpenZeppelin V5 and V4 contracts. Specify your desired version in your project's package.json:

"resolutions": {
"@openzeppelin/contracts": "^5.0.1",
}
tip

LayerZero also provides create-lz-oapp, an npx package that allows developers to create any omnichain application in <4 minutes! Get started by running the following from your command line:

npx create-lz-oapp@latest

Constructing an OFT Contract

Constructing an OFT refers to deploying an OFT contract on every supported chain that you wish to have a token on.

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

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { OFT } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oft/OFT.sol";

contract MyOFT is OFT {
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {}
}
tip

Remember to add the ERC20 _mint method either in the constructor or as a protected mint function before deploying.


This contract contains everything necessary to launch an omnichain ERC20 and can be deployed immediately! It also can be highly customized if you wish to add extra functionality. Let's dive in!

Under the hood, OFT.sol simply extends ERC20.sol, the standard for fungible tokens running on the Ethereum Virtual Machine (EVM), by inheriting OFTCore.sol.

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

import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IOFT, OFTCore } from "./OFTCore.sol";

/**
* @title OFT Contract
* @dev OFT is an ERC-20 token that extends the functionality of the OFTCore contract.
*/
abstract contract OFT is OFTCore, ERC20 {
/**
* @dev Constructor for the OFT contract.
* @param _name The name of the OFT.
* @param _symbol The symbol of the OFT.
* @param _lzEndpoint The LayerZero endpoint address.
* @param _delegate The delegate capable of making OApp configurations inside of the endpoint.
*/
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) ERC20(_name, _symbol) OFTCore(decimals(), _lzEndpoint, _delegate) {}
// ... contract continues
}

This design allows OFT.sol to facilitate cross-chain token transfers while maintaining compatibility with the ERC20 token standard and extensions. Any ERC20 compatible token library can be used with LayerZero's OFT Standard.

By default, the OFT follows ERC20 convention and uses a value of 18 for decimals. To use a different value, you will need to override the decimals() function in your contract.

OFTCore

Most of the LayerZero cross-chain messaging logic can be found within OFTCore.sol. This contract implements the OApp related functions like _lzSend, _lzReceive, and sendCompose, while also defining the core OFT interface that every OFT variant should adhere to.

OFT.sol overrides the _debit and _credit methods found in OFTCore.sol to use the ERC20 internal _burn and _mint methods respectively during cross-chain token transfer.

Other OFT variants will override _debit and _credit differently depending on implementation (e.g., OFTAdapter.sol overrides _debit and _credit to use ERC20.safeTransferFrom to lock / unlock tokens from the OFT Adapter contract itself).

You can also override these methods to add additional functionality to the base transfer logic, which will be explored below.

Token Supply Cap

When transferring tokens across different blockchain VMs, each chain may have a different level of decimal precision for the smallest unit of a token.

While EVM chains support uint256 for token balances, many non-EVM environments use uint64. Because of this, the default OFT Standard has a max token supply (2^64 - 1)/(10^6), or 18,446,744,073,709.551615.

info

If your token's supply needs to exceed this limit, you'll need to override the shared decimals value.

Optional: Overriding sharedDecimals

This shared decimal precision is essentially the maximum number of decimal places that can be reliably represented and handled across different blockchain VMs when transferring tokens.

By default, an OFT has 6 sharedDecimals, which is optimal for most ERC20 use cases that use 18 decimals.

// @dev Sets an implicit cap on the amount of tokens, over uint64.max() will need some sort of outbound cap / totalSupply cap
// Lowest common decimal denominator between chains.
// Defaults to 6 decimal places to provide up to 18,446,744,073,709.551615 units (max uint64).
// For tokens exceeding this totalSupply(), they will need to override the sharedDecimals function with something smaller.
// ie. 4 sharedDecimals would be 1,844,674,407,370,955.1615
function sharedDecimals() public view virtual returns (uint8) {
return 6;
}

To modify this default, simply override the sharedDecimals function to return another value.

caution

Shared decimals also control how token transfer precision is calculated.

Token Transfer Precision

The OFT Standard also handles differences in decimal precision before every cross-chain transfer by "cleaning" the amount from any decimal precision that cannot be represented in the shared system.

The OFT Standard defines these small token transfer amounts as "dust".

Example

Vanilla OFTs use a local decimal value of 18 (the norm for ERC20 tokens), and a shared decimal value of 6.

decimalConversionRate = 10^(localDecimals − sharedDecimals) = 10^(18−6) = 10^12

This means the conversion rate is 10^12, which indicates the smallest unit that can be transferred is 10^-12 in terms of the token's local decimals.

For example, if you send a value of 1234567890123456789 (a token amount with 18 decimals), the OFT Standard will:

  1. Divides by decimalConversionRate:
1234567890123456789 / 10^12 = 1234567.890123456789 = 1234567
tip

Remember that solidity performs integer arithmetic. This means when you divide two integers, the result is also an integer with the fractional part discarded.


  1. Multiplies by decimalConversionRate:
1234567 * 10^12 = 1234567000000000000

This process removes the last 12 digits from the original amount, effectively "cleaning" the amount from any "dust" that cannot be represented in a system with 6 decimal places.

/**
* @dev Internal function to remove dust from the given local decimal amount.
* @param _amountLD The amount in local decimals.
* @return amountLD The amount after removing dust.
*
* @dev Prevents the loss of dust when moving amounts between chains with different decimals.
* @dev eg. uint(123) with a conversion rate of 100 becomes uint(100).
*/
function _removeDust(uint256 _amountLD) internal view virtual returns (uint256 amountLD) {
return (_amountLD / decimalConversionRate) * decimalConversionRate;
}
tip

In summary, this adjustment via the _removeDust function prevents OFT transfers from a potential loss of value due to rounding errors between different VMs, and should be called after determining the actual transfer amount (e.g., after deducting fees).

Adding Send Logic

When calling the send function, _debit is invoked, triggering the OFT's internal ERC20 _burn method to be invoked.

function send(
SendParam calldata _sendParam,
MessagingFee calldata _fee,
address _refundAddress
) external payable virtual returns (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt) {
// @dev Applies the token transfers regarding this send() operation.
// - amountSentLD is the amount in local decimals that was ACTUALLY sent/debited from the sender.
// - amountReceivedLD is the amount in local decimals that will be received/credited to the recipient on the remote OFT instance.
(uint256 amountSentLD, uint256 amountReceivedLD) = _debit(
_sendParam.amountLD,
_sendParam.minAmountLD,
_sendParam.dstEid
);

// ...
}

You can override the _debit function with any additional logic you want to execute before the message is sent via the protocol, for example, taking custom fees.

All of the previous functions use the _debitView function to handle how many tokens should be debited on the source chain, versus credited on the destination.

This function can be overridden, allowing your OFT to implement custom fees by changing the amountSentLD and amountReceivedLD amounts:

// @dev allows the quote functions to mock sending the actual values that would be sent in a send()
function _debitView(
uint256 _amountLD,
uint256 _minAmountLD,
uint32 /*_dstEid*/
) internal view virtual returns (uint256 amountSentLD, uint256 amountReceivedLD) {
// @dev Remove the dust so nothing is lost on the conversion between chains with different decimals for the token.
amountSentLD = _removeDust(_amountLD);
// @dev The amount to send is the same as amount received in the default implementation.
amountReceivedLD = amountSentLD;

// @dev Check for slippage.
if (amountReceivedLD < _minAmountLD) {
revert SlippageExceeded(amountReceivedLD, _minAmountLD);
}
}
caution

The highlighted line above demonstrates how the OFT is safe from overflow because it reduces the size of _amountLD to a value that fits within the expected range of the destination chain's precision by calling _removeDust.

This method looks at the desired amount of tokens to transfer and only allows the sender to send values that meet the allowed decimal precision.

If you add fees to _debitView, make sure you implement the fee before calling _removeDust, so that the OFT can still maintain the correct level of decimal precision.

Review Token Transfer Precision to learn more about removing dust values.

Adding Receive Logic

Similar to send, you can add custom logic when receiving an ERC20 token transfer on the destination chain by overriding the _credit function.

function _credit(
address _to,
uint256 _amountToCreditLD,
uint32 /*_srcEid*/
) internal virtual override returns (uint256 amountReceivedLD) {
_mint(_to, _amountToCreditLD);
return _amountToCreditLD;
}

Setting Delegates

In an OFT, a delegate can be assigned to implement custom configurations on behalf of the contract owner. This delegate gains the ability to handle various critical tasks such as setting configurations and skipping inbound packets for the OFT.

By default, the contract owner is set as the delegate. The setDelegate function allows for changing this, but you should generally keep the contract owner as delegate.

function setDelegate(address _delegate) public onlyOwner {
endpoint.setDelegate(_delegate);
}

For instructions on how to implement custom configurations after setting your delegate, refer to the OApp Configuration.

Security and Governance

Given the impact associated with deployment, configuration, and debugging functions, OFT owners may want to add additional security measures in place to call core contract functions instead of onlyOwner, such as:

  • Governance Controls: Implementing a governance mechanism where decisions to clear messages are voted upon by stakeholders.

  • Multisig Deployment: Deploying with a multisig wallet, preventing arbitrary actions by any one team member.

  • Timelocks: Using a timelock to delay the execution of the clear function, giving stakeholders time to react if the function is called inappropriately.

info

Any normal access control library can be added to the base OFT Standard. The only relevant difference is that these access controls will need to coordinate across multiple contract implementations, since a deployed OFT typically consists of an OFT contract on every connected chain.

Deployment & Usage

You can now deploy your contracts and get one step closer to moving fungible tokens between chains.

Setting Trusted Peers

You should only connect your OFT deployments together after setting your DVN and Executor configuration (see the ethers or CLI tool guide for more on this).

Once you've finished configuring your OFT, you can connect your OFT deployment to different chains by calling setPeer.

The function takes 2 arguments: _eid, the endpoint ID for the destination chain that the other OFT contract lives on, and _peer, the destination OFT's contract address in bytes32 format.

// @dev must-have configurations for standard OApps
function setPeer(uint32 _eid, bytes32 _peer) public virtual onlyOwner {
peers[_eid] = _peer; // Array of peer addresses by destination.
emit PeerSet(_eid, _peer); // Event emitted each time a peer is set.
}
caution

setPeer opens your OFT to start receiving messages from the messaging channel, meaning you should configure any application settings you intend on changing prior to calling setPeer.


danger

OFTs need setPeer to be called correctly on both contracts to send messages. The peer address uses bytes32 for handling non-EVM destination chains.

If the peer has been set to an incorrect destination address, your messages will not be delivered and handled properly. If not resolved, users can burn source funds without a corresponding mint on destination. You can confirm the peer address is the expected destination OFT address by using the isPeer function.


The LayerZero Endpoint will use this peer as the destination address when sending the cross-chain message:

// @dev the endpoint send method called by _lzSend
endpoint.send{ value: messageValue }(
MessagingParams(_dstEid, _getPeerOrRevert(_dstEid), _message, _options, _fee.lzTokenFee > 0),
_refundAddress
);

The destination Endpoint will check if the _receiver matches the OFT contract's expected peer before delivering the message on the destination chain:

function _initializable(
Origin calldata _origin,
address _receiver,
uint64 _lazyInboundNonce
) internal view returns (bool) {
return
_lazyInboundNonce > 0 || // allowInitializePath already checked
ILayerZeroReceiver(_receiver).allowInitializePath(_origin);
}

To see if an address is the trusted peer you expect for a destination, you can read the peers mapping directly:

/**
* @dev Internal function to check if peer is considered 'trusted' by the OApp.
* @param _eid The endpoint ID to check.
* @param _peer The peer to check.
* @return Whether the peer passed is considered 'trusted' by the OApp.
*
* @dev Enables OAppPreCrimeSimulator to check whether a potential Inbound Packet is from a trusted source.
*/
function isPeer(uint32 _eid, bytes32 _peer) public view virtual override returns (bool) {
return peers[_eid] == _peer;
}

This can be useful for confirming whether setPeer has been called correctly and as expected.

Message Execution Options

_options are a generated bytes array with specific instructions for the DVNs and Executor to use when handling the authentication and execution of received messages.

You can find how to generate all the available _options in Message Execution Options, but for this tutorial we'll focus on how options work with OFT.

  • ExecutorLzReceiveOption: instructions for how much gas the Executor should use when calling lzReceive on the destination Endpoint.

For example, usually to send a vanilla OFT to a destination chain you will need 60000 wei in native gas on destination. The options will look like the following:

_options = 0x0003010011010000000000000000000000000000ea60;
tip

ExecutorLzReceiveOption specifies a quote paid in advance on the source chain by the msg.sender for the equivalent amount of native gas to be used on the destination chain. If the actual cost to execute the message is less than what was set in _options, there is no default way to refund the sender the difference. Application developers need to thoroughly profile and test gas amounts to ensure consumed gas amounts are correct and not excessive.

Setting Enforced Options

Once you determine ideal message _options, you will want to make sure users adhere to it. In the case of OFT, you mostly want to make sure the gas is enough for transferring the ERC20 token, plus any additional logic.

A typical OFT's lzReceive call will use 60000 gas on most EVM chains, so you can enforce this option to require callers to pay a 60000 gas limit in the source chain transaction to prevent out of gas issues:

_options = 0x0003010011010000000000000000000000000000ea60;
tip

You can use the create-lz-oapp npx package to set enforcedOptions in a human readable format by defining your settings in your layerzero.config.ts.

The setEnforcedOptions function allows the contract owner to specify mandatory execution options, making sure that the application behaves as expected when users interact with it.

// inherited from EnforcedOptions.sol inside OApp
function setEnforcedOptions(
EnforcedOptionParam[] calldata _enforcedOptions
) external virtual onlyOwner {
for (uint i = 0; i < _enforcedOptions.length; i++) {
uint16 optionsType = uint16(bytes2(_enforcedOptions[i].options[0:2]));
if (optionsType != TYPE_3) revert InvalidOptions(); // not supported for options type 1 and 2
enforcedOptions[_enforcedOptions[i].eid][_enforcedOptions[i].msgType] = _enforcedOptions[i].options;
}

emit SetEnforcedOption(_enforcedOptions);
}

To use setEnforcedOptions, we only need to pass one parameter:

  • EnforcedOptionParam[]: a struct specifying the execution options per message type and destination chain.

    struct EnforcedOptionParam {
    uint32 eid; // destination endpoint id
    uint16 msgType; // the message type
    bytes options; // the execution option bytes array
    }

    The OFT Standard only has handling for 2 message types:

    // @dev execution types to handle different enforcedOptions
    uint16 internal constant SEND = 1; // a standard token transfer via send()
    uint16 internal constant SEND_AND_CALL = 2; // a composed token transfer via send()

    Pass these values in when specifying the msgType for your _options.

For best practice, generate this array off-chain and pass it as a parameter when configuring your OFT:

// Generate the EnforcedOptionParam[] array
let enforcedOptions = [
{
eid: /*your destination endpoint id here*/,
msgType: 1,
options: /*your options here. Our one would be 0x00030100110100000000000000000000000000030d40;*/
},
{
eid: /*your destination endpoint id here*/,
msgType: 2,
options: /*your options here*/
}
// ... add more destinations parameters as needed
];

// Call the setEnforcedOptions function
await contract.setEnforcedOptions(enforcedOptions);
caution

When setting enforcedOptions, try not to unintentionally pass a duplicate _options argument to extraOptions. Passing identical _options in both enforcedOptions and extraOptions will cause the protocol to charge the caller twice on the source chain, because LayerZero interprets duplicate _options as two separate requests for gas.

Setting Extra Options

Any _options passed in the send call itself should be considered _extraOptions.

_extraOptions can specify additional handling within the same message type. These _options will then be combined with enforcedOption if set.

If not needed in your application, you should pass an empty bytes array 0x.

if (enforced.length > 0) {
// combine extra options with enforced options
// remove the first 2 bytes (TYPE_3) of extra options
// should pack executor options last in enforced options (assuming most extra options are executor options only)
// to save gas on grouping by worker id in message library
uint16 extraOptionsType = uint16(bytes2(_extraOptions[0:2]));
uint16 enforcedOptionsType = (uint16(uint8(enforced[0])) << 8) + uint8(enforced[1]);
if (extraOptionsType != enforcedOptionsType) revert InvalidOptions();
options = bytes.concat(enforced, _extraOptions[2:]);
} else {
// no enforced options, use extra options directly
options = _extraOptions;
}
caution

As outlined above, decide on whether you need an application wide option via enforcedOptions or a call specific option using extraOptions. Be specific in what _options you use for both parameters, as your transactions will reflect the exact settings you implement.

Estimating Gas Fees

Now let's get an estimate of how much gas a transfer will cost to be sent and received.

To do this we can call the quoteSend function to return an estimate from the Endpoint contract to use as a recommended msg.value.

Arguments of the estimate function:

  1. SendParam: what parameters should be used for the send call?

    /**
    * @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.
    }
note

Here is a link to further explain Extra Message Options that would be used besides enforcedOptions.

  1. _payInLzToken: what token will be used to pay for the transaction?

    struct MessagingFee {
    uint nativeFee; // gas amount in native gas token
    uint lzTokenFee; // gas amount in ZRO token
    }
/**
* @notice Provides a quote for the send() operation.
* @param _sendParam The parameters for the send() operation.
* @param _payInLzToken Flag indicating whether the caller is paying in the LZ token.
* @return msgFee The calculated LayerZero messaging fee from the send() operation.
*
* @dev MessagingFee: LayerZero msg fee
* - nativeFee: The native fee.
* - lzTokenFee: The lzToken fee.
*/
function quoteSend(
SendParam calldata _sendParam,
bool _payInLzToken
) external view virtual returns (MessagingFee memory msgFee) {
// @dev mock the amount to receive, this is the same operation used in the send().
// The quote is as similar as possible to the actual send() operation.
(, uint256 amountReceivedLD) = _debitView(_sendParam.amountLD, _sendParam.minAmountLD, _sendParam.dstEid);

// @dev Builds the options and OFT message to quote in the endpoint.
(bytes memory message, bytes memory options) = _buildMsgAndOptions(_sendParam, amountReceivedLD);

// @dev Calculates the LayerZero fee for the send() operation.
return _quote(_sendParam.dstEid, message, options, _payInLzToken);
}

Calling send

Since the send logic has already been defined, we'll instead view how the function should be called.

// @dev executes a cross-chain OFT swap via layerZero Endpoint
function send(
SendParam calldata _sendParam,
MessagingFee calldata _fee,
address _refundAddress
) external payable virtual returns (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt) {
// @dev Applies the token transfers regarding this send() operation.
// - amountSentLD is the amount in local decimals that was ACTUALLY sent/debited from the sender.
// - amountReceivedLD is the amount in local decimals that will be received/credited to the recipient on the remote OFT instance.
(uint256 amountSentLD, uint256 amountReceivedLD) = _debit(
_sendParam.amountLD,
_sendParam.minAmountLD,
_sendParam.dstEid
);

// @dev Builds the options and OFT message to quote in the endpoint.
(bytes memory message, bytes memory options) = _buildMsgAndOptions(_sendParam, amountReceivedLD);

// @dev Sends the message to the LayerZero endpoint and returns the LayerZero msg receipt.
msgReceipt = _lzSend(_sendParam.dstEid, message, options, _fee, _refundAddress);
// @dev Formulate the OFT receipt.
oftReceipt = OFTReceipt(amountSentLD, amountReceivedLD);

emit OFTSent(msgReceipt.guid, _sendParam.dstEid, msg.sender, amountSentLD, amountReceivedLD);
}

To do this, we only need to pass send a few inputs:

  1. SendParam: what parameters should be used for the send call?

     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.
    }
note

extraOptions: should Extra Message Options be used besides enforcedOptions?

  1. _fee: what token will be used to pay for the transaction?

    struct MessagingFee {
    uint nativeFee; // gas amount in native gas token
    uint lzTokenFee; // gas amount in ZRO token
    }
  2. _refundAddress: If the transaction fails on the source chain, where should funds be refunded?

Optional: _composedMsg

When sending an OFT, you can also include an optional _composedMsg parameter in the transaction to execute additional logic on the destination chain as part of the token transfer.

// @dev executes an omnichain OFT swap via layerZero Endpoint
if (_composeMsg.length > 0) {
// @dev Remote chains will want to know the composed function caller.
// ALSO, the presence of a composeFrom msg.sender inside of the bytes array indicates the payload should
// be composed. ie. this allows users to compose with an empty payload, vs it must be length > 0
_composeMsg = abi.encodePacked(OFTMsgCodec.addressToBytes32(msg.sender), _composeMsg);
}

msgReceipt = _sendInternal(
_send,
combineOptions(_send.dstEid, SEND_AND_CALL, _extraOptions),
_msgFee, // message fee
_refundAddress, // refund address for failed source tx
_composeMsg // composed message
);

On the destination chain, the _lzReceive function will first process the token transfer, crediting the recipient's account with the specified amount, and then check if _message.isComposed().

if (_message.isComposed()) {
bytes memory composeMsg = OFTComposeMsgCodec.encode(
_origin.nonce, // nonce of the origin transaction
_origin.srcEid, // source endpoint id of the transaction
amountLDReceive, // the token amount in local decimals to credit
_message.composeMsg() // the composed message
);
// @dev Stores the lzCompose payload that will be executed in a separate tx.
// standardizes functionality for delivering/executing arbitrary contract invocation on some non evm chains.
// @dev Composed toAddress is the same as the receiver of the oft/tokens
endpoint.deliverComposedMessage(toAddress, _guid, composeMsg);
}

If the message is composed, the contract retrieves and re-encodes the additional composed message information, then delivers the message to the endpoint, which will execute the additional logic as a separate transaction.

Optional: _oftCmd

The _oftCmd is a bytes array that can be used like a function selector on the destination chain that you can check for within _lzReceive similar to lzCompose for custom OFT implementations.

_lzReceive tokens

A successful send call will be delivered to the destination chain, invoking the provided _lzReceive method during execution:

function _lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata _message,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal virtual override {
// @dev sendTo is always a bytes32 as the remote chain initiating the call doesnt know remote chain address size
address toAddress = _message.sendTo().bytes32ToAddress();

uint256 amountToCreditLD = _toLD(_message.amountSD());
uint256 amountReceivedLD = _credit(toAddress, amountToCreditLD, _origin.srcEid);

if (_message.isComposed()) {
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 Composed toAddress is the same as the receiver of the oft/tokens
// TODO need to document the index / understand how to use it properly
endpoint.sendCompose(toAddress, _guid, 0, composeMsg);
}

emit OFTReceived(_guid, toAddress, amountToCreditLD, amountReceivedLD);
}

_credit:

When receiving the message on your destination contract, _credit is invoked, triggering the final steps to mint an ERC20 token on the destination to the specified address.

function _credit(
address _to,
uint256 _amountToCreditLD,
uint32 /*_srcEid*/
) internal virtual override returns (uint256 amountReceivedLD) {
_mint(_to, _amountToCreditLD);
return _amountToCreditLD;
}