Skip to main content
Version: Endpoint V2 Docs

Omnichain Composability

Cross-chain composability has long been a goal for developers building advanced, interconnected decentralized applications.

LayerZero V2 introduces horizontal composability — a concept that empowers developers to spread out cross-chain calls into multiple, discrete steps.

Prerequisites

Before diving into LayerZero V2 Horizontal Composability, it's essential to have a foundational understanding of the following concepts:

Having familiarity with these topics will enable a smoother comprehension of the concepts discussed.

Workflow

LayerZero V2 supports both Vertical and Horizontal Composability within cross-chain calls.

What is Vertical Composability?

Vertical Composability is the traditional model of composability in blockchain applications, where multiple function calls from different contracts are stacked within a single transaction.

// Example of vertical composability with atomicity
function _lzReceive(
Origin calldata /*_origin*/,
bytes32 /*_guid*/,
bytes calldata /*_message*/,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal override {
contractA.functionA();
contractB.functionB();
contractC.functionC();
// If any of the above calls fail, the entire transaction reverts
}

All function calls in the stack execute atomically. This means that either all operations succeed, or the entire transaction reverts if any single operation fails.

caution

Vertical composability can present potential Atomicity Issues in cross-chain interactions:

  • If an operation on one contract fails, it can produce unintended reversions or inconsistencies across the entire stack. This limits the ability to have instant finality guarantees when receiving cross-chain messages.

In cross-chain contracts, you should minimize the impact of potential message failure by performing only one action per message.

What is Horizontal Composability?

Horizontal Composability is an implementation in LayerZero V2 to address the limitations of vertical composability in cross-chain interactions.

Unlike vertical composability, which relies on a single, linear stack of function calls, horizontal composability allows for multiple, sequential calls across different chains within a single overarching operation.

This facilitates the orchestration of complex, multi-step interactions across multiple chains without being constrained by the depth or complexity of a single call stack.

How Horizontal Composability Works

LayerZero's horizontal composability leverages composed messages that are treated as separate, containerized message packets. These packets are processed independently, allowing for more flexible and controlled interactions across chains.

Workflow Overview:

  1. Sending Application Logic: The sender application uses the OApp._lzSend() function to dispatch a cross-chain message.

  2. Receiving Application Logic: A destination application receives the message from EndpointV2.lzReceive(), does some state change, and then calls EndpointV2.sendCompose() to send a new message to the target composer.

    info

    Crucially, either the sender or receiver should construct an additional message directed at a composer, which will handle subsequent operations in a new method, EndpointV2.lzCompose().

    This dual-message approach ensures that both the immediate and follow-up actions are clearly defined and routed appropriately.

  3. Composer Application Logic: A composer application receives the composed message in lzCompose() and does a state change to follow up on the first state changes created in lzReceive().

This workflow creates a way for delivering some critical state change information in separate steps, reducing the complexity of the call stack and enabling non-critical reverts on the destination chain.

Horizontally Composing Supported Contracts

Implementing horizontal composability involves crafting composed messages to expand on existing cross-chain contract workflows. By default, both the OFT and ONFT standards support horizontally composed calls out of the box.

This allows OFT or ONFT token holders to send tokens cross-chain to a trusted composer contract on the destination, and trigger some action on behalf of the token holders (e.g., token swaps, token staking, etc).

For more advanced implementations, you can design complex OApp contracts that have other cross-chain composer implications.

Installation

To create a composer contract, you can install the OApp package to an existing project:

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",
}

Usage

To implement a composer contract, simply inherit the IOAppComposer.sol interface from the oapp-evm package:

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

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

/**
* @title Composer
* @notice Demonstrates the minimum `IOAppComposer` interface necessary to receive composed messages via LayerZero.
* @dev Implements the `lzCompose` function to process incoming composed messages.
*/
contract Composer is IOAppComposer {

/**
* @notice Address of the LayerZero Endpoint.
*/
address public immutable endpoint;

/**
* @notice Address of the OApp that is sending the composed message.
*/
address public immutable oApp;

/**
* @notice Constructs the contract and initializes state variables.
* @dev Stores the LayerZero Endpoint and OApp addresses.
*
* @param _endpoint The address of the LayerZero Endpoint.
* @param _oApp The address of the OApp that is sending composed messages.
*/
constructor(address _endpoint, address _oApp) {
endpoint = _endpoint;
oApp = _oApp;
}

/**
* @notice Handles incoming composed messages from LayerZero.
* @dev Ensures the message comes from the correct OApp and is sent through the authorized endpoint.
*
* @param _oApp The address of the OApp that is sending the composed message.
*/
function lzCompose(
address _oApp,
bytes32 /* _guid */,
bytes calldata /* _message */,
address /* _executor */,
bytes calldata /* _extraData */
) external payable override {
// Ensure the composed message comes from the correct OApp.
require(_oApp == oApp, "ComposedReceiver: Invalid OApp");
require(msg.sender == endpoint, "ComposedReceiver: Unauthorized sender");
// ... execute logic for handling composed messages
}
}

Composed Message Execution Options

Longer composer messages, which contain more bytes encoded instructions, increase the cost of calling EndpointV2.lzReceive().

Typically, the reason for the gas increase can be found in the additional length being added to your cross-chain message, as well as the cost of invoking EndpointV2.sendCompose() inside your OApp._lzReceive() function.

Ensure that when calling OFT.send() and ONFT.send() or your own custom OApp, that you correctly estimate the cost of calling endpoint.sendCompose() and add the additional LzReceiveOption gas limit to your SendParam.extraOptions or OApp specific options argument:

// addExecutorLzReceiveOption(uint128 _gas, uint128 _value)
Options.newOptions().addExecutorLzReceiveOption(50000, 0);

Besides the increase cost of EndpointV2.lzReceive(), you should also take into account the cost of your actual composer.lzCompose(). Similar to lzReceive(), you can specify the gas limit and msg.value the Executor should use when calling the composer contract:

// addExecutorLzComposeOption(uint16 _index, uint128 _gas, uint128 _value)
Options.newOptions().addExecutorLzReceiveOption(50000, 0).addExecutorLzComposeOption(0, 30000, 0);
  • _index: Identifies the specific composed call within a batch of composed messages. This allows for distinct execution settings for each call.

  • _gas: Specifies the gas limit allocated for the composed call's execution on the destination chain. Gas requirements may vary across chains due to different opcode costs and gas mechanisms.

  • _value: Determines the amount of native currency (e.g., ETH) to be sent alongside the composed call, facilitating payable functions or covering additional costs.

Review the existing documentation on Message Execution Options to learn more.

caution

If not enough gas limit or msg.value is provided, the EndpointV2.lzReceive() will not execute, and will need to be manually retried either via the LayerZero Scan explorer, or manual contract call.

Composing an OFT / ONFT

Both the OFT and ONFT support sending a composed message along with the cross-chain token transfers.

// IOFT.sol

/**
* @dev Struct representing token parameters for the OFT send() operation.
*/
struct SendParam {
uint32 dstEid; // Destination endpoint ID.
bytes32 to; // Composer address.
uint256 amountLD; // Amount to send in local decimals.
uint256 minAmountLD; // Minimum amount to send in local decimals.
bytes extraOptions; // Compose 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.
}

When calling send(), specify the composer as the to address, encode a composeMsg based on the composer's specification, and add a ComposeExecutionOption gas limit and/or msg.value depending on the composer's needs.

When creating the composeMsg, the OFT / ONFT will already encode specific parameters along with your message for use in the composer.

Below is how the OFTCore contract encodes the composeMsg and sends it to the composer:

// OFTCore.sol

/**
* @dev The `OFTMsgCodec` provides a helper function to extract the `composeMsg` from
* the overall message. This ensures that the `composeMsg` is properly formed and can
* be processed by the composer.
*
* @notice The `composeMsg` includes both:
* - The `msg.sender` on the source chain (as bytes32).
* - The actual `composeMsg` intended for the composer.
*
* @notice The final encoded message structure is:
* abi.encodePacked(_sendTo, _amountShared, addressToBytes32(msg.sender), _composeMsg);
*/
using OFTMsgCodec for bytes;

/**
* @dev When sending a message, the `composeMsg` is encoded alongside standard parameters.
*/
(message, hasCompose) = OFTMsgCodec.encode(_sendParam.to, _toSD(_amountLD), _sendParam.composeMsg());

/**
* @dev If the message is composed (i.e., it contains a `composeMsg`),
* we extract it and send it to the composer.
*/
if (_message.isComposed()) {
/**
* @dev The `composeMsg` sent to the composer includes:
* - `_origin.nonce` (to track the originating transaction).
* - `_origin.srcEid` (the source chain endpoint ID).
* - The actual `composeMsg` extracted from `_message`.
*/
bytes memory composeMsg = ONFTComposeMsgCodec.encode(_origin.nonce, _origin.srcEid, _message.composeMsg());

/**
* @dev Sends the composed message to the specified `toAddress` (the composer).
*
* @notice The `composeIndex` is always `0` because batching is not implemented.
* - If batching is added, the index will need to be properly tracked.
*/
endpoint.sendCompose(toAddress, _guid, 0 /* the index of composed message */, composeMsg);
}

This means that in your composer application, you can decode the msg.sender for specific checks, along with the other composer encodings.

For example, see the following composer example which mocks an ERC20 token swap after receiving from an OFT:

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

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

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

/**
* @title SwapMock Contract
* @notice Mocks an ERC20 token swap in response to receiving an OFT message via LayerZero.
* @dev This contract interacts with LayerZero's Omnichain Fungible Token (OFT) Standard,
* processing incoming OFT messages (`lzCompose`) and executing a token swap action.
*/
contract SwapMock is IOAppComposer {
using SafeERC20 for IERC20;

/// @notice The ERC20 token used for swaps.
IERC20 public erc20;

/// @notice Address of the LayerZero Endpoint.
address public immutable endpoint;

/// @notice Address of the OApp that is sending the composed message.
address public immutable oApp;

/**
* @notice Emitted when a token swap is executed.
* @dev This event logs the swap details, including the recipient, token, and amount swapped.
*
* @param user The address of the user who receives the swapped tokens.
* @param tokenOut The address of the ERC20 token being swapped.
* @param amount The amount of tokens swapped.
*/
event Swapped(address indexed user, address tokenOut, uint256 amount);

/**
* @notice Constructs the `SwapMock` contract.
* @dev Initializes the contract by setting the ERC20 token, LayerZero endpoint, and OApp address.
*
* @param _erc20 The address of the ERC20 token that will be used in swaps.
* @param _endpoint The LayerZero Endpoint address.
* @param _oApp The address of the OApp that is sending the composed message.
*/
constructor(address _erc20, address _endpoint, address _oApp) {
erc20 = IERC20(_erc20);
endpoint = _endpoint;
oApp = _oApp;
}

/**
* @notice Handles incoming composed messages from LayerZero and executes a token swap.
* @dev Decodes the `composeMsg` from `_message`, extracts relevant parameters, and transfers
* tokens to the intended recipient.
*
* The `message` is structured in the sender's contract and includes:
* - `_nonce`: A unique identifier for tracking the message.
* - `_srcEid`: The source endpoint ID, identifying the originating chain.
* - `_amountLD`: The amount of tokens in local decimals being transferred.
* - `_composeFrom`: The address of the original sender (encoded as `bytes32`).
* - `_composeMsg`: The payload containing the recipient address.
*
* @param _oApp The address of the originating OApp.
* @param _message The encoded message containing the `composeMsg`.
*/
function lzCompose(
address _oApp,
bytes32 /*_guid*/,
bytes calldata _message,
address /*_executor*/,
bytes calldata /*_extraData*/
) external payable override {
require(_oApp == oApp, "SwapMock: Invalid OApp");
require(msg.sender == endpoint, "SwapMock: Unauthorized sender");

// Decode the nonce (unique identifier for the transaction)
uint64 _nonce = OFTComposeMsgCodec.nonce(_message);

// Decode the source endpoint ID (originating chain)
uint32 _srcEid = OFTComposeMsgCodec.srcEid(_message);

// Decode the amount in local decimals being transferred
uint256 _amountLD = OFTComposeMsgCodec.amountLD(_message);

// Decode the `composeFrom` address (original sender) from bytes32 to address
bytes32 _composeFromBytes = OFTComposeMsgCodec.composeFrom(_message);
address _composeFrom = OFTComposeMsgCodec.bytes32ToAddress(_composeFromBytes);

// Decode the actual `composeMsg` payload to extract the recipient address
bytes memory _actualComposeMsg = OFTComposeMsgCodec.composeMsg(_message);
address _receiver = abi.decode(_actualComposeMsg, (address));

// Execute the token swap by transferring `_amountLD` to `_receiver`
erc20.safeTransfer(_receiver, _amountLD);

// Emit an event for logging the swap details
emit Swapped(_receiver, address(erc20), _amountLD);
}
}

Composing an OApp

  1. Source OApp: Sends a cross-chain message via _lzSend() to a destination chain.

  2. Destination OApp: Receives the cross-chain message via _lzReceive() and initiates composed calls using EndpointV2.sendCompose():

/**
* @dev Handles incoming LayerZero messages and sends a composed message using `endpoint.sendCompose()`.
* @notice This function processes received packets and relays them to a composed receiver.
*
* @param _guid A globally unique identifier for tracking the packet.
* @param payload The encoded message payload.
*/
function _lzReceive(
Origin calldata /*_origin*/,
bytes32 _guid,
bytes calldata payload,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal override {
/**
* @dev Decode the payload based on the expected format from the sender application.
* The structure of `payload` depends entirely on how the sender encoded it.
* In this case, we assume the sender encoded a string message and a composer address.
* If the sender encodes different types or a different order, this decoding must be updated accordingly.
*/
(string memory _message, address _composedAddress) = abi.decode(payload, (string, address));

// Store received data in the destination OApp
data = _message;

// Send a composed message to the composed receiver using the same GUID
endpoint.sendCompose(_composedAddress, _guid, 0, payload);
}
  1. Composer: Contracts that implement business logic to handle incoming composed messages via EndpointV2.lzCompose().