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 either debiting (burn
/ lock
) tokens on the source chain, sending a message via LayerZero, and delivering a function call to credit (mint
/ unlock
) the same number of tokens on the destination chain.
This creates a unified supply across all networks that the OFT supports.
OFT.sol
_burn
the spender's amount on the source chain (Chain A), triggering a new token to _mint
on the target chain (Chain B), via the paired OFT contract.
OFT.sol
extends the base OApp.sol
's bridging logic and inherits ERC20
, meaning your OFT contract address supports IERC20
directly:
OFTAdapter.sol
ERC20.safeTransferFrom
the spender to the OFT Adapter contract, triggering a _mint
of the same amount on the selected destination chain (Chain B) via the paired OFT Contract.
To unlock the tokens in the source chain's OFT Adapter, you will call OFT.send
(Chain B), triggering the token _burn
, and sending a message via the protocol to ERC20.safeTransfer
out of the Adapter to the receiving address (Chain A).
OFTAdapter.sol
supports ERC20 tokens, but itself does not inherit the ERC20 contract. Instead, you can call OFTAdapter.token()
to see the connected ERC20 token.
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.
If you prefer reading the contract code, see the OFT contract in the LayerZero Devtools OFT Package.
Installation
To start using the OFT
and OFTAdapter
contracts, you can install the OFT package to an existing project:
- npm
- yarn
- pnpm
- forge
npm install @layerzerolabs/oft-evm
yarn add @layerzerolabs/oft-evm
pnpm add @layerzerolabs/oft-evm
forge install https://github.com/LayerZero-Labs/devtools
forge install https://github.com/LayerZero-Labs/layerzero-v2
Then add to your foundry.toml
under [profile.default]
:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
'@layerzerolabs/oft-evm/=lib/devtools/packages/oft-evm/',
'@layerzerolabs/oapp-evm/=lib/devtools/packages/oapp-evm/',
'@layerzerolabs/lz-evm-protocol-v2/=lib/layerzero-v2/packages/layerzero-v2/evm/protocol',
]
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
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",
}
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
To create an OFT, deploy the OFT contract on every chain you want the token to exist on.
If your token already exists on the chain you want to connect, you can deploy the OFT Adapter contract to act as an intermediary lockbox for the token.
- OFT
- OFT Adapter
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { OFT } from "@layerzerolabs/oft-evm/contracts/OFT.sol";
/// @notice OFT is an ERC-20 token that extends the OFTCore contract.
contract MyOFT is OFT {
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {}
}
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.
Under the hood, OFT.sol
extends ERC20.sol
, by inheriting OFTCore.sol
. OFT also overrides _debit
and _credit
to use the ERC20 _mint
and _burn
methods:
// 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) {}
/**
* @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() public 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 _from The address to debit the tokens from.
* @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(
address _from,
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(_from, 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) {
if (_to == address(0x0)) _to = address(0xdead); // _mint(...) does not support address(0x0)
// @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;
}
}
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.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;
import { OFTAdapter } from "@layerzerolabs/oft-evm/contracts/OFTAdapter.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
/// @notice OFTAdapter uses a deployed ERC-20 token and safeERC20 to interact with the OFTCore contract.
contract MyOFTAdapter is OFTAdapter {
constructor(
address _token,
address _lzEndpoint,
address _owner
) OFTAdapter(_token, _lzEndpoint, _owner) Ownable(_owner) {}
}
There can only be one OFT Adapter used in an OFT deployment. Multiple OFT Adapters break omnichain unified liquidity by effectively creating token pools. If you create OFT Adapters on multiple chains, you have no way to guarantee finality for token transfers due to the fact that the source chain has no knowledge of the destination pool's supply (or lack of supply). This can create race conditions where if a sent amount exceeds the available supply on the destination chain, those sent tokens will be permanently lost.
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.
Under the hood, OFTAdapter.sol
uses the SafeERC20.sol
library to handle transferring tokens to and from the Adapter contract by overriding OFTCore's _debit
and _credit
methods:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import { IERC20Metadata, IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IOFT, OFTCore } from "./OFTCore.sol";
/**
* @title OFTAdapter Contract
* @dev OFTAdapter is a contract that adapts an ERC-20 token to the OFT functionality.
*
* @dev For existing ERC20 tokens, this can be used to convert the token to crosschain compatibility.
* @dev WARNING: ONLY 1 of these should exist for a given global mesh,
* unless you make a NON-default implementation of OFT and needs to be done very carefully.
* @dev WARNING: The default OFTAdapter implementation assumes LOSSLESS transfers, ie. 1 token in, 1 token out.
* IF the 'innerToken' applies something like a transfer fee, the default will NOT work...
* a pre/post balance check will need to be done to calculate the amountSentLD/amountReceivedLD.
*/
abstract contract OFTAdapter is OFTCore {
using SafeERC20 for IERC20;
IERC20 internal immutable innerToken;
/**
* @dev Constructor for the OFTAdapter contract.
* @param _token The address of the ERC-20 token to be adapted.
* @param _lzEndpoint The LayerZero endpoint address.
* @param _delegate The delegate capable of making OApp configurations inside of the endpoint.
*/
constructor(
address _token,
address _lzEndpoint,
address _delegate
) OFTCore(IERC20Metadata(_token).decimals(), _lzEndpoint, _delegate) {
innerToken = IERC20(_token);
}
/**
* @dev Retrieves the address of the underlying ERC20 implementation.
* @return The address of the adapted ERC-20 token.
*
* @dev In the case of OFTAdapter, address(this) and erc20 are NOT the same contract.
*/
function token() public view returns (address) {
return address(innerToken);
}
/**
* @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 default OFTAdapter, approval is required.
* @dev In non-default OFTAdapter contracts with something like mint and burn privileges, it would NOT need approval.
*/
function approvalRequired() external pure virtual returns (bool) {
return true;
}
/**
* @dev Locks tokens from the sender's specified balance in this contract.
* @param _from The address to debit from.
* @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.
*
* @dev msg.sender will need to approve this _amountLD of tokens to be locked inside of the contract.
* @dev WARNING: The default OFTAdapter implementation assumes LOSSLESS transfers, ie. 1 token in, 1 token out.
* IF the 'innerToken' applies something like a transfer fee, the default will NOT work...
* a pre/post balance check will need to be done to calculate the amountReceivedLD.
*/
function _debit(
address _from,
uint256 _amountLD,
uint256 _minAmountLD,
uint32 _dstEid
) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) {
(amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid);
// @dev Lock tokens by moving them into this contract from the caller.
innerToken.safeTransferFrom(_from, address(this), 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.
*
* @dev WARNING: The default OFTAdapter implementation assumes LOSSLESS transfers, ie. 1 token in, 1 token out.
* IF the 'innerToken' applies something like a transfer fee, the default will NOT work...
* a pre/post balance check will need to be done to calculate the amountReceivedLD.
*/
function _credit(
address _to,
uint256 _amountLD,
uint32 /*_srcEid*/
) internal virtual override returns (uint256 amountReceivedLD) {
// @dev Unlock the tokens and transfer to the recipient.
innerToken.safeTransfer(_to, _amountLD);
// @dev In the case of NON-default OFTAdapter, the amountLD MIGHT not be == amountReceivedLD.
return _amountLD;
}
}
Deployment Workflow
Deploy the
OFT
to all the chains you want to connect.Since
OFT
extendsOApp
, callOFT.setPeer
to whitelist each destination contract on every destination chain.// The real endpoint ids will vary per chain, and can be found under "Supported Chains"
uint32 aEid = 1;
uint32 bEid = 2;
MyOFT aOFT;
MyOFT bOFT;
function addressToBytes32(address _addr) public pure returns (bytes32) {
return bytes32(uint256(uint160(_addr)));
}
// Call on both sides per pathway
aOFT.setPeer(bEid, addressToBytes32(address(bOFT)));
bOFT.setPeer(aEid, addressToBytes32(address(aOFT)));Set the DVN configuration, including optional settings such as block confirmations, security threshold, the Executor, max message size, and send/receive libraries.
EndpointV2.setSendLibrary(aOFT, bEid, newLib)
EndpointV2.setReceiveLibrary(aOFT, bEid, newLib, gracePeriod)
EndpointV2.setReceiveLibraryTimeout(aOFT, bEid, lib, gracePeriod)
EndpointV2.setConfig(aOFT, sendLibrary, sendConfig)
EndpointV2.setConfig(aOFT, receiveLibrary, receiveConfig)
EndpointV2.setDelegate(delegate)These custom configurations will be stored on-chain as part of
EndpointV2
, along with your respectiveSendLibrary
andReceiveLibrary
:// LayerZero V2 MessageLibManager.sol (part of EndpointV2.sol)
mapping(address sender => mapping(uint32 dstEid => address lib)) internal sendLibrary;
mapping(address receiver => mapping(uint32 srcEid => address lib)) internal receiveLibrary;
mapping(address receiver => mapping(uint32 srcEid => Timeout)) public receiveLibraryTimeout;
// LayerZero V2 SendLibBase.sol (part of SendUln302.sol)
mapping(address oapp => mapping(uint32 eid => ExecutorConfig)) public executorConfigs;
// LayerZero V2 UlnBase.sol (both in SendUln302.sol and ReceiveUln302.sol)
mapping(address oapp => mapping(uint32 eid => UlnConfig)) internal ulnConfigs;
// LayerZero V2 EndpointV2.sol
mapping(address oapp => address delegate) public delegates;You can find example scripts to make these calls under Security and Executor Configuration.
dangerThese configurations control the verification mechanisms of messages sent between your OApps. You should review the above settings carefully.
If no configuration is set, the configuration will fallback to the default configurations set by LayerZero Labs. For example:
/// @notice The Send Library is the Oapp specified library that will be used to send the message to the destination
/// endpoint. If the Oapp does not specify a Send Library, the default Send Library will be used.
/// @dev If the Oapp does not have a selected Send Library, this function will resolve to the default library
/// configured by LayerZero
/// @return lib address of the Send Library
/// @param _sender The address of the Oapp that is sending the message
/// @param _dstEid The destination endpoint id
function getSendLibrary(address _sender, uint32 _dstEid) public view returns (address lib) {
lib = sendLibrary[_sender][_dstEid];
if (lib == DEFAULT_LIB) {
lib = defaultSendLibrary[_dstEid];
if (lib == address(0x0)) revert Errors.LZ_DefaultSendLibUnavailable();
}
}(Recommended) The OFT inherits
OAppOptionsType3
, meaning you can enforce specific gas settings when users callaOFT.send
.EnforcedOptionParam[] memory aEnforcedOptions = new EnforcedOptionParam[](1);
// Send gas for lzReceive (A -> B).
aEnforcedOptions[0] = EnforcedOptionParam({eid: bEid, msgType: SEND, options: OptionsBuilder.newOptions().addExecutorLzReceiveOption(50000, 0)}); // gas limit, msg.value
aOFT.setEnforcedOptions(aEnforcedOptions);Required only for
OFTAdapter
: Approve yourOFTAdapter
as a spender of yourERC20
token for the token amount you want to transfer by callingERC20.approve
. This comes standard in theERC20
interface, and is required when using an intermediary contract to spend token amounts on behalf of the caller. See more details about each setting below.
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
.
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.
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:
- Divides by
decimalConversionRate
:
1234567890123456789 / 10^12 = 1234567.890123456789 = 1234567
Remember that solidity performs integer arithmetic. This means when you divide two integers, the result is also an integer with the fractional part discarded.
- 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;
}
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.
/**
* @dev Executes the send operation.
* @param _sendParam The parameters for the send operation.
* @param _fee The calculated fee for the send() operation.
* - nativeFee: The native fee.
* - lzTokenFee: The lzToken fee.
* @param _refundAddress The address to receive any excess funds.
* @return msgReceipt The receipt for the send operation.
* @return oftReceipt The OFT receipt information.
*
* @dev MessagingReceipt: LayerZero msg receipt
* - guid: The unique identifier for the sent message.
* - nonce: The nonce of the sent message.
* - fee: The LayerZero fee incurred for the message.
*/
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(
msg.sender,
_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);
}
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 Internal function to mock the amount mutation from a OFT debit() operation.
* @param _amountLD The amount to send in local decimals.
* @param _minAmountLD The minimum amount to send in local decimals.
* @dev _dstEid The destination endpoint ID.
* @return amountSentLD The amount sent, in local decimals.
* @return amountReceivedLD The amount to be received on the remote chain, in local decimals.
*
* @dev This is where things like fees would be calculated and deducted from the amount to be received on the remote.
*/
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);
}
}
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.
/**
* @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) {
if (_to == address(0x0)) _to = address(0xdead); // _mint(...) does not support address(0x0)
// @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;
}
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.
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 Configuration Guide or create-lz-oapp
CLI tool).
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.
}
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
.
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 callinglzReceive
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;
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;
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 `oapp/libs/OAppOptionsType3.sol`:
/**
* @dev Sets the enforced options for specific endpoint and message type combinations.
* @param _enforcedOptions An array of EnforcedOptionParam structures specifying enforced options.
*
* @dev Only the owner/admin of the OApp can call this function.
* @dev Provides a way for the OApp to enforce things like paying for PreCrime, AND/OR minimum dst lzReceive gas amounts etc.
* @dev These enforced options can vary as the potential options/execution on the remote may differ as per the msgType.
* eg. Amount of lzReceive() gas necessary to deliver a lzCompose() message adds overhead you dont want to pay
* if you are only making a standard LayerZero message ie. lzReceive() WITHOUT sendCompose().
*/
function setEnforcedOptions(EnforcedOptionParam[] calldata _enforcedOptions) public virtual onlyOwner {
_setEnforcedOptions(_enforcedOptions);
}
function _setEnforcedOptions(EnforcedOptionParam[] memory _enforcedOptions) internal virtual {
for (uint256 i = 0; i < _enforcedOptions.length; i++) {
// @dev Enforced options are only available for optionType 3, as type 1 and 2 dont support combining.
_assertOptionsType3(_enforcedOptions[i].options);
enforcedOptions[_enforcedOptions[i].eid][_enforcedOptions[i].msgType] = _enforcedOptions[i].options;
}
emit EnforcedOptionSet(_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:
EnforcedOptionParam[] memory aEnforcedOptions = new EnforcedOptionParam[](1);
// Send gas for lzReceive (A -> B).
aEnforcedOptions[0] = EnforcedOptionParam({eid: bEid, msgType: SEND, options: OptionsBuilder.newOptions().addExecutorLzReceiveOption(65000, 0)});
// Call the setEnforcedOptions function
aOFT.setEnforcedOptions(aEnforcedOptions);
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;
}
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:
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.
}
Here is a link to further explain Extra Message Options that would be used besides enforcedOptions
.
_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.
- Hardhat Task
- Foundry Script
import {task} from 'hardhat/config';
import {getNetworkNameForEid, types} from '@layerzerolabs/devtools-evm-hardhat';
import {EndpointId} from '@layerzerolabs/lz-definitions';
import {addressToBytes32} from '@layerzerolabs/lz-v2-utilities';
import {Options} from '@layerzerolabs/lz-v2-utilities';
import {BigNumberish, BytesLike} from 'ethers';
interface Args {
amount: string;
to: string;
toEid: EndpointId;
}
interface SendParam {
dstEid: EndpointId; // Destination endpoint ID, represented as a number.
to: BytesLike; // Recipient address, represented as bytes.
amountLD: BigNumberish; // Amount to send in local decimals.
minAmountLD: BigNumberish; // Minimum amount to send in local decimals.
extraOptions: BytesLike; // Additional options supplied by the caller to be used in the LayerZero message.
composeMsg: BytesLike; // The composed message for the send() operation.
oftCmd: BytesLike; // The OFT command to be executed, unused in default OFT implementations.
}
// send tokens from a contract on one network to another
task('lz:oft:send', 'Sends tokens from either OFT or OFTAdapter')
.addParam('to', 'contract address on network B', undefined, types.string)
.addParam('toEid', 'destination endpoint ID', undefined, types.eid)
.addParam('amount', 'amount to transfer in token decimals', undefined, types.string)
.setAction(async (taskArgs: Args, {ethers, deployments}) => {
const toAddress = taskArgs.to;
const eidB = taskArgs.toEid;
// Get the contract factories
const oftDeployment = await deployments.get('MyOFT');
const [signer] = await ethers.getSigners();
// Create contract instances
const oftContract = new ethers.Contract(oftDeployment.address, oftDeployment.abi, signer);
const decimals = await oftContract.decimals();
const amount = ethers.utils.parseUnits(taskArgs.amount, decimals);
let options = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toBytes();
// Now you can interact with the correct contract
const oft = oftContract;
const sendParam: SendParam = {
dstEid: eidB,
to: addressToBytes32(toAddress),
amountLD: amount,
minAmountLD: amount,
extraOptions: options,
composeMsg: ethers.utils.arrayify('0x'), // Assuming no composed message
oftCmd: ethers.utils.arrayify('0x'), // Assuming no OFT command is needed
};
// Get the quote for the send operation
const feeQuote = await oft.quoteSend(sendParam, false);
const nativeFee = feeQuote.nativeFee;
console.log(
`sending ${taskArgs.amount} token(s) to network ${getNetworkNameForEid(eidB)} (${eidB})`,
);
const ERC20Factory = await ethers.getContractFactory('ERC20');
const innerTokenAddress = await oft.token();
// // If the token address !== address(this), then this is an OFT Adapter
// if (innerTokenAddress !== oft.address) {
// // If the contract is OFT Adapter, get decimals from the inner token
// const innerToken = ERC20Factory.attach(innerTokenAddress);
// // Approve the amount to be spent by the oft contract
// await innerToken.approve(oftDeployment.address, amount);
// }
const r = await oft.send(sendParam, {nativeFee: nativeFee, lzTokenFee: 0}, signer.address, {
value: nativeFee,
});
console.log(`Send tx initiated. See: https://layerzeroscan.com/tx/${r.hash}`);
});
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import { IOAppCore } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppCore.sol";
import { SendParam, OFTReceipt } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol";
import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol";
import { MessagingFee } from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";
import { MyOFT } from "../contracts/MyOFT.sol";
contract SendOFT is Script {
using OptionsBuilder for bytes;
/**
* @dev Converts an address to bytes32.
* @param _addr The address to convert.
* @return The bytes32 representation of the address.
*/
function addressToBytes32(address _addr) internal pure returns (bytes32) {
return bytes32(uint256(uint160(_addr)));
}
function run() public {
// Fetching environment variables
address oftAddress = vm.envAddress("OFT_ADDRESS");
address toAddress = vm.envAddress("TO_ADDRESS");
uint256 _tokensToSend = vm.envUint("TOKENS_TO_SEND");
// Fetch the private key from environment variable
uint256 privateKey = vm.envUint("PRIVATE_KEY");
// Start broadcasting with the private key
vm.startBroadcast(privateKey);
MyOFT sourceOFT = MyOFT(oftAddress);
bytes memory _extraOptions = OptionsBuilder.newOptions().addExecutorLzReceiveOption(65000, 0);
SendParam memory sendParam = SendParam(
30111, // You can also make this dynamic if needed
addressToBytes32(toAddress),
_tokensToSend,
_tokensToSend * 9 / 10,
_extraOptions,
"",
""
);
MessagingFee memory fee = sourceOFT.quoteSend(sendParam, false);
console.log("Fee amount: ", fee.nativeFee);
sourceOFT.send{value: fee.nativeFee}(sendParam, fee, msg.sender);
// Stop broadcasting
vm.stopBroadcast();
}
}
Below you can find the send method itself.
// @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(
msg.sender,
_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:
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.
}
extraOptions
allow a caller to define an additional amount of gas_limit
and msg.value
to deliver to the destination chain along with the required amount set by the contract owner (enforcedOptions
).
_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
}_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;
}