Skip to main content
Version: Endpoint V2 Docs

LayerZero V2 OApp Quickstart

The OApp standard lets your contract send and receive arbitrary messages across chains. With OApp, you can update on-chain state on one network and trigger custom business logic on another.

OApp Example OApp Example

OApp.sol implements the core interface for calling LayerZero's Endpoint V2 on EVM chains. It also provides hookable _lzSend and _lzReceive methods so you can inject your own business logic:

OApp Inheritance OApp Inheritance

tip

If your use case only involves cross-chain token transfers, consider inheriting the OFT Standard instead of OApp.

Installation

To start using LayerZero contracts in a new project, use the LayerZero CLI tool, create-lz-oapp. The CLI tool is 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 --example oapp

This will create an example repository containing both the Hardhat and Foundry frameworks, LayerZero development utilities, as well as the OApp contract package pre-installed.

To use LayerZero contracts in an existing project, you can install the OApp package directly:

npm install @layerzerolabs/oapp-evm
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",
}

Custom OApp Contract

To build your own cross-chain application, inherit from OApp.sol and implement two key pieces:

  1. Send business logic: how you encode and dispatch a custom _message on the source
  2. Receive business logic: how you decode and apply an incoming _message on the destination

Below is a complete example skeleton structure showing:

  • A constructor wiring in the local Endpoint and owner
  • A sendString(...) function that updates state, encodes a string, and calls _lzSend(...)
  • An override of _lzReceive(...) that decodes the string and applies business logic
  • (Optional) a quoteSendString(...) function to query the fee details needed to call sendString(...)
// 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 Last string received from any remote chain
string public lastMessage;

/// @notice Msg type for sending a string, for use in OAppOptionsType3 as an enforced option
uint16 public constant SEND = 1;

/// @notice Initialize with Endpoint V2 and owner address
/// @param _endpoint The local chain's LayerZero Endpoint V2 address
/// @param _owner The address permitted to configure this OApp
constructor(address _endpoint, address _owner) OApp(_endpoint, _owner) Ownable(_owner) {}

// ──────────────────────────────────────────────────────────────────────────────
// 0. (Optional) Quote business logic
//
// Example: Get a quote from the Endpoint for a cost estimate of sending a message.
// Replace this to mirror your own send business logic.
// ──────────────────────────────────────────────────────────────────────────────

/**
* @notice Quotes the gas needed to pay for the full omnichain transaction in native gas or ZRO token.
* @param _dstEid Destination chain's endpoint ID.
* @param _string The string to send.
* @param _options Message execution options (e.g., for sending gas to destination).
* @param _payInLzToken Whether to return fee in ZRO token.
* @return fee A `MessagingFee` struct containing the calculated gas fee in either the native token or ZRO token.
*/
function quoteSendString(
uint32 _dstEid,
string calldata _string,
bytes calldata _options,
bool _payInLzToken
) public view returns (MessagingFee memory fee) {
bytes memory _message = abi.encode(_string);
// combineOptions (from OAppOptionsType3) merges enforced options set by the contract owner
// with any additional execution options provided by the caller
fee = _quote(_dstEid, _message, combineOptions(_dstEid, SEND, _options), _payInLzToken);
}

// ──────────────────────────────────────────────────────────────────────────────
// 1. Send business logic
//
// Example: send a simple string to a remote chain. Replace this with your
// own state-update logic, then encode whatever data your application needs.
// ──────────────────────────────────────────────────────────────────────────────

/// @notice Send a string to a remote OApp on another chain
/// @param _dstEid Destination Endpoint ID (uint32)
/// @param _string The string to send
/// @param _options Execution options for gas on the destination (bytes)
function sendString(uint32 _dstEid, string calldata _string, bytes calldata _options) external payable {
// 1. (Optional) Update any local state here.
// e.g., record that a message was "sent":
// sentCount += 1;

// 2. Encode any data structures you wish to send into bytes
// You can use abi.encode, abi.encodePacked, or directly splice bytes
// if you know the format of your data structures
bytes memory _message = abi.encode(_string);

// 3. Call OAppSender._lzSend to package and dispatch the cross-chain message
// - _dstEid: remote chain's Endpoint ID
// - _message: ABI-encoded string
// - _options: combined execution options (enforced + caller-provided)
// - MessagingFee(msg.value, 0): pay all gas as native token; no ZRO
// - payable(msg.sender): refund excess gas to caller
//
// combineOptions (from OAppOptionsType3) merges enforced options set by the contract owner
// with any additional execution options provided by the caller
_lzSend(
_dstEid,
_message,
combineOptions(_dstEid, SEND, _options),
MessagingFee(msg.value, 0),
payable(msg.sender)
);
}

// ──────────────────────────────────────────────────────────────────────────────
// 2. Receive business logic
//
// Override _lzReceive to decode the incoming bytes and apply your logic.
// The base OAppReceiver.lzReceive ensures:
// • Only the LayerZero Endpoint can call this method
// • The sender is a registered peer (peers[srcEid] == origin.sender)
// ──────────────────────────────────────────────────────────────────────────────

/// @notice Invoked by OAppReceiver when EndpointV2.lzReceive is called
/// @dev _origin Metadata (source chain, sender address, nonce)
/// @dev _guid Global unique ID for tracking this message
/// @param _message ABI-encoded bytes (the string we sent earlier)
/// @dev _executor Executor address that delivered the message
/// @dev _extraData Additional data from the Executor (unused here)
function _lzReceive(
Origin calldata /*_origin*/,
bytes32 /*_guid*/,
bytes calldata _message,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal override {
// 1. Decode the incoming bytes into a string
// You can use abi.decode, abi.decodePacked, or directly splice bytes
// if you know the format of your data structures
string memory _string = abi.decode(_message, (string));

// 2. Apply your custom logic. In this example, store it in `lastMessage`.
lastMessage = _string;

// 3. (Optional) Trigger further on-chain actions.
// e.g., emit an event, mint tokens, call another contract, etc.
// emit MessageReceived(_origin.srcEid, _string);
}
}

Constructor

  • Pass the Endpoint V2 address and owner address into the base contracts.
    • OApp(_endpoint, _owner) binds your contract to the local LayerZero Endpoint V2 and registers the owner as the delegate, making it the only address that can change configurations (such as libraries, DVNs, and Executors.
    • Ownable(_owner) makes _owner the only address that can change configurations (such as peers, enforced options, and delegate).
  • After deployment, the owner can call:
    • setConfig(...) to adjust library or DVN parameters
    • setSendLibrary(...) and setReceiveLibrary(...) to override default libraries
    • setPeer(...) to whitelist remote OApp addresses
    • setDelegate(...) to assign a different delegate address
info

A full overview of how to use these adminstrative functions can be found below under Deployment & Wiring.

sendString(...)

  1. Update local state (optional)

    • Before sending, you might update a counter, lock tokens, or perform any on-chain action specific to your app.
  2. Encode the message

    • Use abi.encode(_message), abi.encodePacked(_message), or manual byte shifting/offsets to turn the string into a bytes array. LayerZero packets carry raw bytes, so you must encode any data type into bytes first.
  3. Call _lzSend(...)

    • _dstEid is the destination chain's Endpoint ID. LayerZero uses numeric IDs (e.g., 30101 for Ethereum, 30168 for Solana).
    • _message is the ABI-encoded string (bytes memory).
    • _options is a bytes array specifying gas or executor instructions for the destination. For example, an ExecutorLzReceiveOption tells the destination how much gas to allocate to your receive call.
    • MessagingFee(msg.value, 0) pays fees in native gas. If you wanted to pay in ZRO tokens, set the second field instead.
    • payable(msg.sender) specifies the refund address for any unused gas. This can be any address (EOA or contract), but if it's a contract, the contract must have a fallback function to receive the refund.

_lzReceive(...)

  1. Endpoint verification

    • Only the LayerZero Endpoint V2 contract can invoke this function. The base OAppReceiver enforces that.
    • The call succeeds only if _origin.sender == peers[_origin.srcEid]. In other words, the sender's address must match the registered peer for that source chain.
  2. Decode the incoming bytes

    • Use abi.decode(_message, (string)) to extract the original string. If you sent a different data type (e.g., a struct), decode with the matching types.
    • Alternatively, you can use abi.decodePacked() for packed encoding, or manually splice bytes from specific offsets if you know the exact format of your data structures.
  3. Apply your business logic

    • In this example, we store the decoded string in lastMessage.
    • You could instead:
      • Emit an event (e.g., emit MessageReceived(_origin.srcEid, decoded))
      • Mint or unlock tokens based on the message
      • Call another contract to trigger a downstream workflow
tip

Always include all five parameters (_origin, _guid, _message, _executor, _extraData) in your override. Even if you only use _message, matching the function signature ensures the Endpoint can call your method correctly.

(Optional) quoteSendString(...)

You can optionally call the internal OAppSender._quote(...) method in a public function to provide accurate estimation for the gas cost of calling MyOApp.sendString(...).

The internal _quote method queries the send library selected by the OApp and asks the workers (DVNs and Executor) for fee details for the given encoded message:

  1. Fee estimation before sending

    • Before calling sendString(...), you need to know how much native gas (or ZRO tokens) to send with your transaction. The quoteSendString(...) function provides this cost estimate.
  2. Mirrors send logic

    • The quote function uses the same message encoding (abi.encode(_string)) and option handling (combineOptions(_dstEid, SEND, _options)) as the actual send function, ensuring accurate fee estimates.
  3. Enforced options integration

    • By inheriting OAppOptionsType3 and using combineOptions(...), the quote function automatically includes any enforced options that the contract owner has configured for the SEND message type, plus any additional options provided by the caller.
  4. Flexible payment options

    • The _payInLzToken parameter lets you choose whether to pay fees in the native gas token of the source chain or in ZRO tokens.

      Example usage:

    // Get fee estimate first
    MessagingFee memory fee = myOApp.quoteSendString(
    dstEid,
    "Hello World",
    "0x", // no additional options
    false // pay in native gas
    );

    // Then send with the estimated fee
    myOApp.sendString{value: fee.nativeFee}(
    dstEid,
    "Hello World",
    "0x"
    );

This section shows you exactly:

  • Where to update or check local state before sending
  • How to encode and send your application data over LayerZero
  • Where to decode incoming data and execute your custom logic

Replace the string examples with whatever data structures and state changes your application requires.

Deployment and Wiring

After you finish writing and testing your MyOApp contract, follow these steps to deploy it on each network and wire up the messaging stack.

tip

We strongly recommend using the LayerZero CLI tool to manage your configurations. Our config generator simplifies access to all available deployments across networks and is the preferred method for cross-chain messaging. See the CLI Guide for examples and how to use it in your project.

1. Deploy Your OApp Contract

Deploy MyOApp on each chain using either the LayerZero CLI (recommended) or manual deployment scripts.

After running pnpm compile at the root level of your example repo, you can deploy your contracts.

Network Configuration

Before using the CLI, you'll need to configure your networks in hardhat.config.ts with LayerZero Endpoint IDs and declare an RPC URL in your .env or directly in the config file:

// hardhat.config.ts
import { EndpointId } from '@layerzerolabs/lz-definitions'

// ... rest of hardhat config omitted for brevity
networks: {
'optimism-sepolia-testnet': {
eid: EndpointId.OPTSEP_V2_TESTNET,
url: process.env.RPC_URL_OP_SEPOLIA || 'https://optimism-sepolia.gateway.tenderly.co',
accounts,
},
'avalanche-fuji-testnet': {
eid: EndpointId.AVALANCHE_V2_TESTNET,
url: process.env.RPC_URL_FUJI || 'https://avalanche-fuji.drpc.org',
accounts,
},
'arbitrum-sepolia-testnet': {
eid: EndpointId.ARBSEP_V2_TESTNET,
url: process.env.RPC_URL_ARB_SEPOLIA || 'https://arbitrum-sepolia.gateway.tenderly.co',
accounts,
},
}
info

The key addition to a standard hardhat.config.ts is the inclusion of LayerZero Endpoint IDs (eid) for each network. Check the Deployments section for all available endpoint IDs.

The LayerZero CLI provides automated deployment with built-in endpoint detection based on your hardhat.config.ts networks object:

# Deploy using interactive prompts
npx hardhat lz:deploy

The CLI will prompt you to:

  1. Select chains to deploy to:
? Which networks would you like to deploy? ›
◉ fuji
◉ amoy
◉ sepolia
  1. Choose deploy script tags:
? Which deploy script tags would you like to use? › MyOApp
  1. Confirm deployment:
✔ Do you want to continue? … yes
Network: amoy
Deployer: 0x0000000000000000000000000000000000000000
Network: sepolia
Deployer: 0x0000000000000000000000000000000000000000
Deployed contract: MyOApp, network: amoy, address: 0x0000000000000000000000000000000000000000
Deployed contract: MyOApp, network: sepolia, address: 0x0000000000000000000000000000000000000000

The CLI automatically:

  • Detects the correct LayerZero Endpoint V2 address for each chain
  • Deploys your OApp contract with proper constructor arguments
  • Generates deployment artifacts in ./deployments/ folder
  • Creates network-specific deployment files (e.g., deployments/sepolia/MyOApp.json)

2. Wire Messaging Libraries and Configurations

Once your contracts are on-chain, you must set up send/receive libraries and DVN/Executor settings so cross-chain messages flow correctly.

The LayerZero CLI automatically handles all wiring via a single configuration file and command:

Configuration File

In your project root, you can find a layerzero.config.ts file:

import {EndpointId} from '@layerzerolabs/lz-definitions';
import {ExecutorOptionType} from '@layerzerolabs/lz-v2-utilities';
import {TwoWayConfig, generateConnectionsConfig} from '@layerzerolabs/metadata-tools';
import {OAppEnforcedOption, OmniPointHardhat} from '@layerzerolabs/toolbox-hardhat';

// This contract object defines the OApp deployment on Optimism Sepolia testnet
// The config references the contract deployment from your ./deployments folder
const optimismContract: OmniPointHardhat = {
eid: EndpointId.OPTSEP_V2_TESTNET,
contractName: 'MyOApp',
};

const avalancheContract: OmniPointHardhat = {
eid: EndpointId.AVALANCHE_V2_TESTNET,
contractName: 'MyOApp',
};

const arbitrumContract: OmniPointHardhat = {
eid: EndpointId.ARBSEP_V2_TESTNET,
contractName: 'MyOApp',
};

// For this example's simplicity, we will use the same enforced options values for sending to all chains
// For production, you should ensure `gas` is set to the correct value through profiling the gas usage of calling OApp._lzReceive(...) on the destination chain
// To learn more, read https://docs.layerzero.network/v2/concepts/applications/oapp-standard#execution-options-and-enforced-settings
const EVM_ENFORCED_OPTIONS: OAppEnforcedOption[] = [
{
msgType: 1,
optionType: ExecutorOptionType.LZ_RECEIVE,
gas: 80000,
value: 0,
},
];

// To connect all the above chains to each other, we need the following pathways:
// Optimism <-> Avalanche
// Optimism <-> Arbitrum
// Avalanche <-> Arbitrum

// With the config generator, pathways declared are automatically bidirectional
// i.e. if you declare A,B there's no need to declare B,A
const pathways: TwoWayConfig[] = [
[
optimismContract, // Chain A contract
avalancheContract, // Chain B contract
[['LayerZero Labs'], []], // [ requiredDVN[], [ optionalDVN[], threshold ] ]
[1, 1], // [A to B confirmations, B to A confirmations]
[EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS], // Chain B enforcedOptions, Chain A enforcedOptions
],
[
optimismContract, // Chain A contract
arbitrumContract, // Chain C contract
[['LayerZero Labs'], []], // [ requiredDVN[], [ optionalDVN[], threshold ] ]
[1, 1], // [A to B confirmations, B to A confirmations]
[EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS], // Chain C enforcedOptions, Chain A enforcedOptions
],
[
avalancheContract, // Chain B contract
arbitrumContract, // Chain C contract
[['LayerZero Labs'], []], // [ requiredDVN[], [ optionalDVN[], threshold ] ]
[1, 1], // [A to B confirmations, B to A confirmations]
[EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS], // Chain C enforcedOptions, Chain B enforcedOptions
],
];

export default async function () {
// Generate the connections config based on the pathways
const connections = await generateConnectionsConfig(pathways);
return {
contracts: [
{contract: optimismContract},
{contract: avalancheContract},
{contract: arbitrumContract},
],
connections,
};
}

Make sure your contract object's contractName matches the named deployment file for the network under ./deployments/.

Wire Everything

Run a single command to configure all pathways:

npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts

This automatically handles:

  • Fetching the necessary contract addresses for each network from metadata
  • Setting send and receive libraries
  • Configuring DVNs and Executors
  • Setting up peers between contracts
  • Applying enforced options
  • All bidirectional pathways in your config

Usage

Once deployed and wired, you can begin sending cross-chain messages.

Calling send

The LayerZero CLI provides a convenient task for sending messages that automatically handles fee estimation and transaction execution.

Using the Send Task

The CLI includes a built-in lz:oapp:send task that:

  1. Quotes the gas cost using your OApp's quoteSendString() function
  2. Sends the message with the correct fee
  3. Waits for confirmation and provides tracking links

Basic usage:

npx hardhat lz:oapp:send --dst-eid 30101 --string "Hello ethereum" --network arbitrum-sepolia-testnet

Parameters:

  • --dst-eid: Destination endpoint ID (required)
  • --string: Message to send (required)
  • --network: Source network name from your hardhat config (required)
  • --options: Execution options in hex format (optional, defaults to 0x)

Example output:

Initiating string send from arbitrum-sepolia-testnet to ethereum-sepolia-testnet
String to send: "Hello ethereum"
Destination EID: 30101
Using signer: 0x1234567890123456789012345678901234567890
MyOApp contract found at: 0xabcdefabcdefabcdefabcdefabcdefabcdefabcd
Execution options: 0x
Quoting gas cost for the send transaction...
Native fee: 0.001234567890123456 ETH
LZ token fee: 0 LZ
Sending the string transaction...
Transaction hash: 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
Waiting for transaction confirmation...
Gas used: 123456
Block number: 1234567
✅ SENT_VIA_OAPP: Successfully sent "Hello ethereum" from arbitrum-sepolia-testnet to ethereum-sepolia-testnet
✅ TX_HASH: Block explorer link for source chain arbitrum-sepolia-testnet: https://sepolia.arbiscan.io/tx/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
✅ EXPLORER_LINK: LayerZero Scan link for tracking cross-chain delivery: https://testnet.layerzeroscan.com/tx/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef

The task automatically:

  • Finds your deployed MyOApp contract
  • Quotes the exact gas fee needed
  • Sends the transaction with proper gas estimation
  • Provides block explorer and LayerZero Scan links for tracking

Extensions

The OApp Standard can be extended with various messaging patterns to support complex cross-chain applications. Each pattern functions as a distinct omnichain building block, capable of being used independently or in combination.

ABA (Ping-Pong) Pattern

The ABA pattern enables nested messaging where a message sent from Chain A to Chain B triggers another message back to Chain A (ABA). This is useful for cross-chain authentication, data feeds, or conditional contract execution.

ABA Light ABA Dark

Implementation

The key is to nest an _lzSend call within your _lzReceive function:

function _lzReceive(
Origin calldata _origin,
bytes32 /*_guid*/,
bytes calldata _message,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal override {
// Decode the incoming message
(string memory data, uint16 msgType, bytes memory returnOptions) = abi.decode(_message, (string, uint16, bytes));

// Process the message
lastMessage = data;

if (msgType == SEND_ABA) {
// Send response back to origin chain
_lzSend(
_origin.srcEid,
abi.encode("Response from Chain B", SEND),
returnOptions,
MessagingFee(msg.value, 0),
payable(address(this))
);
}
}
tip

ABA Pattern Gas Planning: When implementing the ABA pattern, consider these important factors:

  1. Encode return options in your message: Include the _options parameter for the B→A transaction within your A→B message encoding, as shown in the example above with returnOptions.

  2. Calculate total gas costs upfront: The source OApp (A) needs to know the full transaction cost for the entire A→B→A flow. You should:

    • Quote the cost of the B→A transaction beforehand
    • Include this cost in your lzReceiveOption gas allocation for the A→B transaction
    • Ensure sufficient msg.value is forwarded to cover both legs of the journey
  3. Example gas calculation:

    // Quote B→A cost first
    MessagingFee memory returnFee = quoteBtoA(returnOptions);

    // Include return fee in A→B options
    bytes memory abaOptions = OptionsBuilder.newOptions()
    .addExecutorLzReceiveOption(baseGas + returnGas, returnFee.nativeFee);

This ensures your ABA transaction has sufficient gas to complete the full round trip.

Batch Send

Batch Send allows a single transaction to initiate multiple _lzSend calls to various destination chains, reducing operational overhead for multi-chain operations.

Batch Send Light Batch Send Dark

Implementation

/// @notice Estimates total LayerZero fees for sending the same message to multiple chains.
/// @param _dstEids Array of destination chain endpoint IDs.
/// @param _string The message string to send.
/// @param _options Extra options (gas, adapter params, etc.).
/// @return totalFee Aggregated native and ZRO fees across all destinations.
function quoteBatchSend(
uint32[] memory _dstEids,
string memory _string,
bytes calldata _options,
bool _payInLzToken
) public view returns (MessagingFee memory totalFee) {
bytes memory _message = abi.encode(_string);
uint256 len = _dstEids.length;

uint256 nativeSum = 0;
uint256 zroSum = 0;

for (uint256 i = 0; i < len; i++) {
uint32 dst = _dstEids[i];
bytes memory opts = combineOptions(dst, SEND, _options);
MessagingFee memory fee = _quote(dst, _message, opts, _payInLzToken);
nativeSum += fee.nativeFee;
zroSum += fee.zroFee;
}

return MessagingFee(nativeSum, zroSum);
}

function batchSend(
uint32[] memory _dstEids,
string memory _string,
bytes calldata _options
) external payable {
bytes memory _message = abi.encode(_string);
uint256 len = _dstEids.length;

// 1) Compute each fee exactly once
MessagingFee[] memory fees = new MessagingFee[](len);
uint256 totalNativeFee = 0;

for (uint256 i = 0; i < len; i++) {
bytes memory opts = combineOptions(_dstEids[i], SEND, _options);
// only one _quote call per destination
fees[i] = _quote(_dstEids[i], _message, opts, /*payInZRO=*/ false);
totalNativeFee += fees[i].nativeFee;
}

// 2) Check up‐front that the caller supplied enough
require(msg.value >= totalNativeFee, "Insufficient fee");

// 3) Now do all the sends, reusing the fees we already fetched
for (uint256 i = 0; i < len; i++) {
bytes memory opts = combineOptions(_dstEids[i], SEND, _options);
_lzSend(
_dstEids[i],
_message,
opts,
fees[i],
payable(msg.sender)
);
}
}

Call Composer

Composed messaging enables horizontal composability where a message triggers external contract calls on the destination chain through lzCompose. Unlike vertical composability (multiple calls in a single transaction), horizontal composability processes operations as separate, containerized message packets.

Composed Light Composed Dark

Benefits of Horizontal Composability

  • Fault Isolation: If a composed call fails, it doesn't revert the main token transfer or message
  • Gas Efficiency: Each step can have independent gas limits and execution options
  • Flexible Workflows: Complex multi-step operations can be broken into manageable pieces

Sending Side

function sendStringToComposer(
uint32 _dstEid,
string memory _string,
address _composer,
bytes calldata _extraOptions
) external payable {
// Include both lzReceive and lzCompose options in enforcedOptions or extraOptions
bytes memory composedOptions = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(65000, 0) // For the main receive
.addExecutorLzComposeOption(0, 50000, 0); // For the compose call

bytes memory _message = abi.encode(_string, _composer);

_lzSend(
_dstEid,
_message,
composedOptions,
MessagingFee(msg.value, 0),
payable(msg.sender)
);
}

Receiving Side

function _lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata _message,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal override {
(string memory _string, address composer) = abi.decode(_message, (string, address));

// Store the message and perform primary logic
lastMessage = _string;

// Send composed message to external contract as separate message packet
endpoint.sendCompose(composer, _guid, 0, _message);
}

Composer Contract

import { IOAppComposer } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppComposer.sol";

contract Composer is IOAppComposer {
address public immutable endpoint;
address public immutable trustedOApp;

constructor(address _endpoint, address _trustedOApp) {
endpoint = _endpoint;
trustedOApp = _trustedOApp;
}

function lzCompose(
address _oApp,
bytes32 /*_guid*/,
bytes calldata _message,
address /*_executor*/,
bytes calldata /*_extraData*/
) external payable override {
// Security checks
require(msg.sender == endpoint, "!endpoint");
require(_oApp == trustedOApp, "!oApp");

// Decode the message payload
(string memory _string, ) = abi.decode(_message, (string, address));

// Execute custom business logic
performCustomAction(_string);
}

function performCustomAction(string memory message) internal {
// Your custom logic here (swap, stake, mint, etc.)
}
}
tip

Execution Options for Composed Messages: You must provide gas for both the main lzReceive call and the lzCompose call:

bytes memory options = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(baseGas, 0) // Main message processing
.addExecutorLzComposeOption(0, composeGas, value); // Composed call (index 0)

The _index parameter allows multiple composed calls with different gas allocations.

Message Ordering

LayerZero supports both unordered (default) and ordered delivery patterns.

Ordered Delivery Implementation

contract OrderedOApp is OApp {
mapping(uint32 eid => mapping(bytes32 sender => uint64 nonce)) private receivedNonce;

function nextNonce(uint32 _srcEid, bytes32 _sender) public view override returns (uint64) {
return receivedNonce[_srcEid][_sender] + 1;
}

function _acceptNonce(uint32 _srcEid, bytes32 _sender, uint64 _nonce) internal override {
receivedNonce[_srcEid][_sender] += 1;
require(_nonce == receivedNonce[_srcEid][_sender], "Invalid nonce");
}

// Must include ExecutorOrderedExecutionOption in your send options
function sendOrdered(uint32 _dstEid, string memory _message) external payable {
bytes memory options = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(200000, 0)
.addExecutorOrderedExecutionOption(); // Required for ordered execution

_lzSend(_dstEid, abi.encode(_message), options, MessagingFee(msg.value, 0), payable(msg.sender));
}
}

Rate Limiting

Control message frequency to prevent spam and ensure controlled cross-chain interactions:

contract RateLimitedOApp is OApp, RateLimiter {
constructor(
address _endpoint,
address _owner,
RateLimitConfig[] memory _rateLimitConfigs
) OApp(_endpoint, _owner) {
_setRateLimits(_rateLimitConfigs);
}

function sendWithRateLimit(
uint32 _dstEid,
string memory _message,
bytes calldata _options
) external payable {
// Check rate limit before sending
_outflow(_dstEid, 1); // 1 message

_lzSend(
_dstEid,
abi.encode(_message),
_options,
MessagingFee(msg.value, 0),
payable(msg.sender)
);
}
}

Further Reading

For detailed implementations and advanced patterns, see:

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.