OApp Composing
A composed message refers to an OApp that invokes the LayerZero Endpoint method sendCompose
to deliver a composed call to another contract on the destination chain via lzCompose
.
This pattern demonstrates horizontal composability, which differs from vertical composability in that the external call is now containerized as a new message packet; enabling the application to ensure that certain receipt logic remains separate from the external call itself.
Since each composable call is created as a separate message packet via lzCompose
, this pattern can be extended for as many steps as your application needs (B1
-> B2
-> B3
, etc).
Composing an OApp
There are 3 relevant contract interactions when composing an OApp:
Source OApp: the OApp sending a cross-chain message via
_lzSend
to a destination.Destination OApp(s): the OApp receiving a cross-chain message via
_lzReceive
and callingsendCompose
.Composed Receiver(s): the contract interface implementing business logic to handle receiving a composed message via
lzCompose
.
Sending Message
The sending OApp is required to pass specific Composed Message Execution Options (more on this below) for the sendCompose
call, but is not required to pass any input parameters for the call itself (however this pattern may be useful depending on what arbitrary action you wish to trigger when composing).
For example, this send
function packs the destination _composedAddress
for the destination OApp to decode and use for the actual composed call.
/// @notice Sends a message from the source to destination chain.
/// @param _dstEid Destination chain's endpoint ID.
/// @param _message The message to send.
/// @param _composedAddress The contract you wish to deliver a composed call to.
/// @param _options Message execution options (e.g., for sending gas to destination).
function send(
uint32 _dstEid,
string memory _message,
address _composedAddress, // the destination contract implementing ILayerZeroComposer
bytes calldata _options
) external payable returns(MessagingReceipt memory receipt) {
// Encodes the message before invoking _lzSend.
bytes memory _payload = abi.encode(_message, _composedAddress);
receipt = _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)
);
}
Sending Compose
The receiving OApp invokes the LayerZero Endpoint's sendCompose
method as part of your OApp's _lzReceive
business logic.
The sendCompose
method takes the following inputs:
_to
: the contract implementing theILayerZeroComposer
receive interface._guid
: the global unique identifier of the source message (provided standard bylzReceive
)._index
: the index of the composed message (used for pricing different gas execution amounts along different composed legs of the transaction).
/// @dev the Oapp sends the lzCompose message to the endpoint
/// @dev the composer MUST assert the sender because anyone can send compose msg with this function
/// @dev with the same GUID, the Oapp can send compose to multiple _composer at the same time
/// @dev authenticated by the msg.sender
/// @param _to the address which will receive the composed message
/// @param _guid the message guid
/// @param _message the message
function sendCompose(address _to, bytes32 _guid, uint16 _index, bytes calldata _message) external {
// must have not been sent before
if (composeQueue[msg.sender][_to][_guid][_index] != NO_MESSAGE_HASH) revert Errors.ComposeExists();
composeQueue[msg.sender][_to][_guid][_index] = keccak256(_message);
emit ComposeSent(msg.sender, _to, _guid, _index, _message);
}
This means that when a packet is received (_lzReceive
) by the Destination OApp, it will send (sendCompose
) a new composed packet via the destination LayerZero Endpoint.
/// @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
(string memory _message, address _composedAddress) = abi.decode(payload, (string, address));
// Storing data in the destination OApp
data = _message;
// Send a composed message[0] to a composed receiver
endpoint.sendCompose(_composedAddress, _guid, 0, payload);
}
The above sendCompose
call hardcodes _index
to 0
and simply forwards the same payload
as _lzReceive
to lzCompose
, however these inputs can also be dynamically adjusted depending on the number and type of composed calls you wish to make.
Composed Message Execution Options
You can decide both the _gas
and msg.value
that should be used for the composed call(s), depending on the type and quantity of messages you intend to send.
Your configured Executor will use the _options
provided in the original _lzSend
call to determine the gas limit and amount of msg.value
to include per message _index
:
// addExecutorLzComposeOption(uint16 _index, uint128 _gas, uint128 _value)
Options.newOptions()
.addExecutorLzReceiveOption(50000, 0)
.addExecutorLzComposeOption(0, 30000, 0)
.addExecutorLzComposeOption(1, 30000, 0);
It's important to remember that gas costs may vary depending on the destination chain. For example, all new Ethereum transactions cost 21000
wei, but other chains may have lower or higher opcode costs, or entirely different gas mechanisms.
You can read more about generating _options
and the role of _index
in Message Execution Options.
Receiving Compose
The destination must implement the ILayerZeroComposer
interface to handle receiving the composed message.
From there, you can decide any additional composed business logic to execute within lzCompose
, as shown below:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import { ILayerZeroComposer } from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroComposer.sol";
/// @title ComposedReceiver
/// @dev A contract demonstrating the minimum ILayerZeroComposer interface necessary to receive composed messages via LayerZero.
contract ComposedReceiver is ILayerZeroComposer {
/// @notice Stores the last received message.
string public data = "Nothing received yet";
/// @notice Store LayerZero addresses.
address public immutable endpoint;
address public immutable oApp;
/// @notice Constructs the contract.
/// @dev Initializes the contract.
/// @param _endpoint LayerZero Endpoint address
/// @param _oApp The address of the OApp that is sending the composed message.
constructor(address _endpoint, address _oApp) {
endpoint = _endpoint;
oApp = _oApp;
}
/// @notice Handles incoming composed messages from LayerZero.
/// @dev Decodes the message payload and updates the state.
/// @param _oApp The address of the originating OApp.
/// @param /*_guid*/ The globally unique identifier of the message.
/// @param _message The encoded message content.
function lzCompose(
address _oApp,
bytes32 /*_guid*/,
bytes calldata _message,
address,
bytes calldata
) external payable override {
// Perform checks to make sure composed message comes from correct OApp.
require(_oApp == oApp, "!oApp");
require(msg.sender == endpoint, "!endpoint");
// Decode the payload to get the message
(string memory message, ) = abi.decode(_message, (string, address));
data = message;
}
}
Further Reading
For more advanced implementations of sendCompose
and lzCompose
:
- Review the
OmniCounter.sol
for sending composed messages to the same OApp implementation.
- Read the OFT Composing section to see how to implement composed business logic into your OFTs.