LayerZero V2 OApp Quickstart
The OApp Standard provides developers with a generic message passing interface to send and receive arbitrary pieces of data between contracts existing on different blockchain networks.
This interface can easily be extended to include anything from specific financial logic in a DeFi application, a voting mechanism in a DAO, and broadly any smart contract use case.
LayerZero provides OApp
for implementing generic message passing in your contracts:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import { OAppSender } from "./OAppSender.sol";
// @dev import the origin so its exposed to OApp implementers
import { OAppReceiver, Origin } from "./OAppReceiver.sol";
import { OAppCore } from "./OAppCore.sol";
abstract contract OApp is OAppSender, OAppReceiver {
constructor(address _endpoint, address _owner) OAppCore(_endpoint, _owner) {}
function oAppVersion() public pure virtual returns (uint64 senderVersion, uint64 receiverVersion) {
senderVersion = SENDER_VERSION;
receiverVersion = RECEIVER_VERSION;
}
}
If you prefer reading the contract code, see the OApp package in the LayerZero Devtools OApp Package.
For developers interested in sending and receiving omnichain tokens, we recommend inheriting the OFT Standard directly instead of OApp.
Installation
To start using LayerZero contracts, you can install the OApp package to an existing project:
- npm
- yarn
- pnpm
- forge
npm install @layerzerolabs/oapp-evm
yarn add @layerzerolabs/oapp-evm
pnpm add @layerzerolabs/oapp-evm
forge install https://github.com/LayerZero-Labs/devtools
forge install https://github.com/LayerZero-Labs/layerzero-v2
Then add to your foundry.toml
:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
'@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
Creating an OApp Contract
Every OApp will need to set two arguments in the constructor:
Endpoint Address: The source chain’s Endpoint Address for communicating with the protocol.
Owner Address: The address that will own the OApp contract.
And define the send and receive function:
_lzSend
: the internal function your application must call to send an omnichain message._lzReceive
: the function to receive an omnichain message. This internal method is called whenever theEndpointV2.lzReceive()
is executed at the receiving OApp.
The OApp Contract Standard inherits directly from both OAppSender.sol
and OAppReceiver.sol
, so that your child contract has handling for both sending and receiving messages. You can inherit directly from either the Sender or Receiver contract if your child contract only needs one type of handling, as shown in Getting Started.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;
import { OApp, Origin, MessagingFee } from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
contract MyOApp is OApp {
constructor(address _endpoint, address _owner) OApp(_endpoint, _owner) Ownable(_owner) {}
// Some arbitrary data you want to deliver to the destination chain!
string public data;
/**
* @notice Sends a message from the source to destination chain.
* @param _dstEid Destination chain's endpoint ID.
* @param _message The message to send.
* @param _options Message execution options (e.g., for sending gas to destination).
*/
function send(
uint32 _dstEid,
string memory _message,
bytes calldata _options
) external payable {
// Encodes the message before invoking _lzSend.
// Replace with whatever data you want to send!
bytes memory _payload = abi.encode(_message);
_lzSend(
_dstEid,
_payload,
_options,
// Fee in native gas and ZRO token.
MessagingFee(msg.value, 0),
// Refund address in case of failed source message.
payable(msg.sender)
);
}
/**
* @dev Called when data is received from the protocol. It overrides the equivalent function in the parent contract.
* Protocol messages are defined as packets, comprised of the following parameters.
* @param _origin A struct containing information about where the packet came from.
* @param _guid A global unique identifier for tracking the packet.
* @param payload Encoded message.
*/
function _lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata payload,
address, // Executor address as specified by the OApp.
bytes calldata // Any extra data or options to trigger on receipt.
) internal override {
// Decode the payload to get the message
// In this case, type is string, but depends on your encoding!
data = abi.decode(payload, (string));
}
}
Deployment Workflow
Deploy the
OApp
to all the chains you want to connect.Call
MyOApp.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;
MyOApp aOApp;
MyOApp bOApp;
function addressToBytes32(address _addr) public pure returns (bytes32) {
return bytes32(uint256(uint160(_addr)));
}
// Call on both sides per pathway
aOApp.setPeer(bEid, addressToBytes32(address(bOApp)));
bOApp.setPeer(aEid, addressToBytes32(address(aOApp)));Set the DVN configuration, including optional settings such as block confirmations, security threshold, the Executor, max message size, and send/receive libraries.
EndpointV2.setSendLibrary(aOApp, bEid, newLib)
EndpointV2.setReceiveLibrary(aOApp, bEid, newLib, gracePeriod)
EndpointV2.setReceiveLibraryTimeout(aOApp, bEid, lib, gracePeriod)
EndpointV2.setConfig(aOApp, sendLibrary, sendConfig)
EndpointV2.setConfig(aOApp, receiveLibrary, receiveConfig)
EndpointV2.setDelegate(delegate)These custom configurations will be stored on-chain as part of EndpointV2 and your respective
SendLibrary
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) Optionally, if you inherit
OAppOptionsType3
, you can enforce specific gas settings when users callaOApp.send
.// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;
import { OApp, Origin, MessagingFee } from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";
import { OAppOptionsType3 } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OAppOptionsType3.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
contract MyOApp is OApp, OAppOptionsType3 {
/// @notice Message types that are used to identify the various OApp operations.
/// @dev These values are used in things like combineOptions() in OAppOptionsType3.
uint16 public constant SEND = 1;
constructor(address _endpoint, address _owner) OApp(_endpoint, _owner) Ownable(_owner) {}
// ... contract continues
}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
aOApp.setEnforcedOptions(aEnforcedOptions);
See more details about each setting below.
Implementing _lzSend
To start sending messages from your OApp, you'll need to call _lzSend
with your own contract logic.
Depending on your application, this might initiate token transfers, burn and mint NFTs, or just pass a simple string between chains.
Example: Sending a String
Consider the scenario where you want to send a simple string _message
to store on a destination chain.
// Sends a message from the source to destination chain.
function send(uint32 _dstEid, string memory _message, bytes calldata _options) external payable {
bytes memory _payload = abi.encode(_message); // Encodes message as bytes.
_lzSend(
_dstEid, // Destination chain's endpoint ID.
_payload, // Encoded message payload being sent.
_options, // Message execution options (e.g., gas to use on destination).
MessagingFee(msg.value, 0), // Fee struct containing native gas and ZRO token.
payable(msg.sender) // The refund address in case the send call reverts.
);
}
You start by first encoding the _message
as a bytes array and passing five arguments to _lzSend
:
_dstEid
: The destination Endpoint ID._message
: The message to be sent._options
: Message execution options for protocol handling (see below).MessagingFee
: what token will be used to pay for the transaction?struct MessagingFee {
uint256 nativeFee; // Fee amount in native gas token.
uint256 lzTokenFee; // Fee amount in ZRO token.
}_refundAddress
: specifies the address to which any excess fees should be refunded.payable(msg.sender) // The address of the user or contract that initiated the transaction.
infoIf your refund address is a smart contract you will need to implement a fallback function in order for it to receive the refund.
Message Execution Options
You might be wondering, what are message execution _options
?
_options
are a generated bytes array with specific instructions for the Security Stack 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 you'll focus on providing the Executor with a gas amount to use when executing our message:
ExecutorLzReceiveOption
: instructions for how much gas should be used when callinglzReceive
on the destination Endpoint.
When generated correctly, the _options
parameter will be used in the Endpoint quote
to ensure enough msg.value
is paid based to match the Executor amount.
For example, to send a vanilla OFT, you usually need 60000
wei in destination native gas during message execution:
_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.
Optional: Enforced Options
Once you determine ideal message _options
, you will want to make sure users adhere to it. In the case of OApp, you mostly want to make sure the gas amount you have included in _options
for the lzReceive
call can be enforced for all callers of _lzSend
, to prevent reverts.
To require a caller to use a specific _options
, your OApp can inherit the enforced options interface IOAppOptionsType3.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import { OApp, Origin, MessagingFee } from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";
import { IOAppOptionsType3 } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppOptionsType3.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
contract MyOApp is OApp, IOAppOptionsType3 {
constructor(address _endpoint, address _owner) OApp(_endpoint, _owner) Ownable(_owner) {}
}
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.
Here is code snippet 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
}
You will need to define your OApp's msgType
and what those messaging types look like. For example, 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()
You will pass these values in when specifying the msgType
for your _options
.
If you're looking for complete example how to set enforced options in Solidity this Foundry test case might be helpful:
function test_combine_options() public {
uint32 eid = 1;
uint16 msgType = 1;
bytes memory enforcedOptions = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200000, 0);
EnforcedOptionParam[] memory enforcedOptionsArray = new EnforcedOptionParam[](1);
enforcedOptionsArray[0] = EnforcedOptionParam(eid, msgType, enforcedOptions);
aOFT.setEnforcedOptions(enforcedOptionsArray);
bytes memory extraOptions = OptionsBuilder.newOptions().addExecutorNativeDropOption(
1.2345 ether,
addressToBytes32(userA)
);
bytes memory expectedOptions = OptionsBuilder
.newOptions()
.addExecutorLzReceiveOption(200000, 0)
.addExecutorNativeDropOption(1.2345 ether, addressToBytes32(userA));
bytes memory combinedOptions = aOFT.combineOptions(eid, msgType, extraOptions);
assertEq(combinedOptions, expectedOptions);
}
Estimating Gas Fees
Often with the LayerZero protocol you'll want to know an estimate of how much gas a message will cost to be sent and received.
To do this you can implement a quote()
function within the OApp contract to return an estimate from the Endpoint contract to use as a recommended msg.value
.
/* @dev Quotes the gas needed to pay for the full omnichain transaction.
* @return nativeFee Estimated gas fee in native gas.
* @return lzTokenFee Estimated gas fee in ZRO token.
*/
function quote(
uint32 _dstEid, // Destination chain's endpoint ID.
string memory _message, // The message to send.
bytes calldata _options, // Message execution options
bool _payInLzToken // boolean for which token to return fee in
) public view returns (uint256 nativeFee, uint256 lzTokenFee) {
bytes memory _payload = abi.encode(_message);
MessagingFee memory fee = _quote(_dstEid, _payload, _options, _payInLzToken);
return (fee.nativeFee, fee.lzTokenFee);
}
The _quote
can be returned in either the native gas token or in ZRO token, supporting both payment methods.
Because cross-chain gas fees are dynamic, this quote should be generated right before calling _lzSend
to ensure accurate pricing.
Make sure that the arguments passed into the quote()
function identically match the parameters used in the lzSend()
function. If parameters mismatch, you may run into errors as your msg.value
will not match the actual gas quote.
Remember that when sending a message through LayerZero, the msg.sender
will be paying for gas on the source chain, fees to the selected DVNs to validate the message, and for gas on the destination chain to execute the transaction. This results in a single bundled fee on the source chain, abstracting gas away on every other chain, leading to better composability.
Implementing _lzReceive
To start receiving messages on a destination, your OApp needs to override the _lzReceive
function.
function _lzReceive(
Origin calldata _origin, // struct containing info about the message sender
bytes32 _guid, // global packet identifier
bytes calldata payload, // encoded message payload being received
address _executor, // the Executor address.
bytes calldata _extraData // arbitrary data appended by the Executor
) internal override {
data = abi.decode(payload, (string)); // your logic here
}
_lzReceive
takes a few main inputs for message handling:
_origin
: a struct generated by the protocol containing information about where the message came from.struct Origin {
uint32 srcEid; // The source chain's Endpoint ID.
bytes32 sender; // The sending OApp address.
uint64 nonce; // The message nonce for the pathway.
}_guid
: a unique identifier for tracking the message.payload
: the message in encoded bytes format._executor
: the address of the Executor calling the Endpoint'slzReceive
function._extraData
: Designed to carry arbitrary data appended by the Executor and passed along with the message payload. Cannot be modified by the OApp.
Even if your receiving OApp contract doesn't use every interface parameter, they must be included to match _lzReceive
's function signature.
What's great about an OApp is that you can define any arbitrary contract logic to trigger within _lzReceive
.
That means that this function could store data, trigger other functions, or even invoke a nested _lzSend
again to trigger an action back on the source chain. For advanced usage, LayerZero provides a full list of Message Design Patterns to experiment with.
Setting Delegates
In a given OApp, a delegate is able to apply configurations on behalf of the OApp. This delegate gains the ability to handle various critical tasks such as setting configurations and MessageLibs, and skipping or clearing payloads.
By default, the contract owner is set as the delegate. The setDelegate
function allows for changing this, but we recommend you always keep 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, OApp owners may want to add additional security measures in place to call core contract functions beyond just the onlyOwner
requirement, 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 certain function, giving stakeholders time to react if the function is called inappropriately.
Usage
That’s it. Once deployed, you just need to complete a few post-deployment requirements.
Setting Peer
Once you've finished your OApp Configuration, you can open the messaging channel and connect your OApp deployments by calling setPeer
.
A peer is required to be set for each EID (or network). Ideally an OApp (or OFT) will have multiple peers set where one and only one peer exists for one EID.
The function takes 2 arguments: _eid
, the destination endpoint ID for the chain our other OApp contract lives on, and _peer
, the destination OApp 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.
}
This function opens your OApp to start receiving messages from the messaging channel, meaning you should configure any application settings you intend on changing prior to calling setPeer
.
OApps 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 potentially pay gas on source without any corresponding action on destination. You can confirm the peer address is the expected destination OApp address by viewing the peers
mapping directly.
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.
Calling send
Once your source and destination chain contracts have successfully been deployed and peers set, you're ready to begin passing messages between them.
Remember to generate a fee estimate using quote
first, and then pass the returned native gas amount as your msg.value
.
> MyOApp.send{value: msg.value}(101, "My first omnichain message!", 0x0003010011010000000000000000000000000000c350)
Tracing and Troubleshooting
You can follow your testnet and mainnet transaction statuses using LayerZero Scan.
Refer to Debugging Messages for any unexpected complications when sending a message.
You can also ask for help or follow development in the Discord.