Skip to main content

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

In this example, you'll create an OFT that is an ERC20 token named Silver (SLVR) for a hypothetical blockchain game.

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

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

contract SLVRToken is OFT {
constructor(
string memory _name, // token name
string memory _symbol, // token symbol
address _layerZeroEndpoint, // local endpoint address
address _owner // token owner used as a delegate in LayerZero Endpoint
) OFT(_name, _symbol, _layerZeroEndpoint, _owner) Ownable(_owner) {
// your contract logic here
_mint(msg.sender, 100 ether); // mints 100 tokens to the deployer
}
}

The SLVRToken inherits from OFT for the base implementation, while allowing for custom contract logic to be added. For example, you can mint 100 tokens to the address that deploys the contract.

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.

This standard has already implemented OApp related functions like _lzSend and _lzReceive. Instead, you will override and use _debit and _credit when writing your own custom OFT logic.

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!

Token Supply Cap

When transferring tokens between EVM and non-EVM chains, it's crucial to consider how each chain stores token balances.

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, or 1,844,674,407,370,955.1615. This ensures that token transfers won't fail due to a loss of precision or unexpected balance conversions.

info

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

Optional: Overriding sharedDecimals

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;
}

Internal functions compare the shared decimal (SD) value with the token decimals (LD) to ensure that balances don't overflow between chains:

// @dev ensure no overflows when converting from amountLD to amountSD
// amountLDMax = amountSDMax * ld2sdRate
// (2^256 - 1) = (2^64 - 1) * ld2sdRate
// (2^256 - 1) = (2^64 - 1) * (10 ^ (_localDecimals - sharedDecimals()))
// (2^256 - 1) = (2^64 - 1) * (10 ^ MAX_LD_SD_DECIMAL_DIFF)
// (2^256 - 1) = (2^64 - 1) * (10 ^ 57.931~)
// ld - sd <= 57;
uint internal immutable ld2sdRate;

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

Adding Send Logic

When calling the send function, _debit is invoked, triggering the ERC20 token on the source chain to be burned.

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 overriden, 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);
}
}

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;
}
caution

Be careful with the ordering of additional logic. Once a token is minted, the transaction is typically irreversible, so special care should be taken to understand how custom logic impacts end users.

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 MessageLibs, and skipping or clearing payloads, for the OFT.

By default, the contract owner is set as the delegate. However, the setDelegate function allows for changing this.

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.

Deployment & Usage

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

Setting Trusted Peers

Once you've finished configuring your OFT, you can open the messaging channel and connect your OFT deployment to different chains by calling setPeer.

The function takes 2 arguments: _eid, the endpoint ID for the destination chain the other OFT contract lives on, and _peer, the destination OFT 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 for 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
);

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, if we want to use 200000 wei in native gas on destination, the options would be:

_options = 0x00030100110100000000000000000000000000030d40;
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 lzReceive call will use 200000 gas on most EVM chains, so you can enforce this option to require callers to pay a 200000 gas limit in the source chain transaction:

_options = 0x00030100110100000000000000000000000000030d40;

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

_oftCmd is a reserved field in the OFT interface that allows customed OFT implementation.

_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;
}