Skip to main content
Version: Endpoint V2

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.

OApp Example OApp Example

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

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

OApp lends access to two essential functions:

  • _lzSend: the function your application must implement to send an omnichain message.

  • _lzReceive: the function to receive an omnichain message.

info

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.

tip

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 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 OApp Contract

You can easily get started building an OApp contract that you'll eventually deploy on multiple blockchains.

First, you'll need to set in the constructor two arguments:

  • the source chain's Endpoint Address: an immutable LayerZero contract deployed to every supported chain, as well as the owner of the OApp.

  • the Ownable contract _owner: an address that owns the OApp contract.

This allows the OApp to communicate with the Endpoint contract to send and receive messages via the protocol.

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

import { OApp, Origin, MessagingFee } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oapp/OApp.sol";

contract MyOApp is OApp {
constructor(address _endpoint, address _owner) OApp(_endpoint, _owner) {}
}

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:

  1. _dstEid: The destination Endpoint ID.

  2. _message: The message to be sent.

  3. _options: Message execution options for protocol handling (see below).

  4. 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.
    }
  5. _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.
    info

    If 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 calling lzReceive 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;
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.

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/lz-evm-oapp-v2/contracts/oapp/OApp.sol";
import { IOAppOptionsType3 } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oapp/interfaces/IOAppOptionsType3.sol";

contract MyOApp is OApp, IOAppOptionsType3 {
constructor(address _endpoint, address _owner) OApp(_endpoint, _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.

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

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.

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.

tip

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.


info

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:

  1. _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.
    }
  2. _guid: a unique identifier for tracking the message.

  3. payload: the message in encoded bytes format.

  4. _executor: the address of the Executor calling the Endpoint's lzReceive function.

  5. _extraData: Designed to carry arbitrary data appended by the Executor and passed along with the message payload. Cannot be modified by the OApp.

note

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.

Deployment & 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.
}
caution

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.


danger

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.