LayerZero V2 Part 5: Composed Messages
This is Part 5 of a series on LayerZero V2. Part 1 covers the basics.
Sometimes you want to bridge tokens AND do something with them in one operation. Bridge USDC to Arbitrum and immediately swap it for ETH. Bridge tokens and stake them. LayerZero V2 calls this composed messages.
The Problem with V1
In V1, composed operations were all-or-nothing. If you bridged + swapped + staked, and the staking failed, the ENTIRE transaction reverted — including the bridge. Your tokens got stuck in limbo.
V2’s Solution: Two Phases
V2 splits composed operations into independent phases:
Phase 1: lzReceive() — Mint/unlock tokens → ALWAYS completes first
Phase 2: lzCompose() — Execute custom action → SEPARATE transactionIf Phase 2 fails, Phase 1 is permanent. Tokens are safely in the user’s wallet. This is called horizontal composability.
How It Works
Sending Side
Include a composeMsg in your SendParam:
const sendParam = {
dstEid: 40231,
to: padAddress(composerContract), // the contract that will receive + act
amountLD: parseEther("100"),
minAmountLD: parseEther("100"),
extraOptions: OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(65000, 0) // gas for Phase 1
.addExecutorLzComposeOption(0, 200000, 0), // gas for Phase 2
composeMsg: encodeSwapData(targetToken), // your custom payload
oftCmd: "0x",
};When composeMsg is non-empty, the message type automatically becomes SEND_AND_CALL.
Receiving Side — Phase 1 (lzReceive)
The OFT contract handles Phase 1 automatically:
function _lzReceive(...) internal override {
// 1. Credit tokens (mint or unlock)
uint256 amountReceivedLD = _credit(toAddress, amount, srcEid);
// 2. If composed message exists, queue it
if (_message.isComposed()) {
bytes memory composeMsg = OFTComposeMsgCodec.encode(
_origin.nonce, _origin.srcEid, amountReceivedLD,
_message.composeMsg()
);
endpoint.sendCompose(toAddress, _guid, 0, composeMsg);
}
emit OFTReceived(_guid, _origin.srcEid, toAddress, amountReceivedLD);
}Tokens are credited first. Then the compose message is queued. Even if something goes wrong later, the tokens are safe.
Receiving Side — Phase 2 (lzCompose)
The Executor triggers Phase 2 in a separate transaction:
function lzCompose(
address _from,
bytes32 _guid,
bytes calldata _message,
address _executor,
bytes calldata _extraData
) external payable {
// SECURITY: Must validate both!
require(msg.sender == address(endpoint), "Only endpoint");
require(_from == address(myOFT), "Only my OFT");
// Decode
uint256 amount = OFTComposeMsgCodec.amountLD(_message);
bytes memory payload = OFTComposeMsgCodec.composeMsg(_message);
// Execute the action
dex.swap(myToken, amount, targetToken);
}The Compose Message Format
Byte Layout:
[0:8] uint64 nonce — Original message nonce
[8:12] uint32 srcEid — Source chain endpoint ID
[12:44] uint256 amountLD — Amount credited (local decimals)
[44:76] bytes32 composeFrom — Who initiated (sender on source chain)
[76:] bytes composeMsg — Your custom payloadUse Cases
| Pattern | Phase 1 | Phase 2 |
|---|---|---|
| Bridge + Swap | Receive tokens | Swap via DEX |
| Bridge + Stake | Receive tokens | Stake into yield protocol |
| Bridge + Deposit | Receive tokens | Deposit into lending pool |
| Bridge + LP | Receive tokens | Add to liquidity pool |
| Bridge + Vote | Receive tokens | Cast governance vote |
Gas Configuration
You MUST allocate gas for both phases separately:
bytes memory options = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(65000, 0) // Phase 1: mint + sendCompose
.addExecutorLzComposeOption(0, 200000, 0); // Phase 2: your custom logicThe first argument to addExecutorLzComposeOption is the compose index. If your _lzReceive calls sendCompose multiple times, each gets a different index.
If your compose logic needs native gas (e.g., for a swap):
.addExecutorLzComposeOption(0, 200000, 0.01 ether) // Phase 2 + 0.01 ETHSecurity: The lzCompose Validation Bug
A common vulnerability: not validating who called lzCompose().
Bad:
function lzCompose(address _from, ...) external {
// No checks! Anyone can call this with fake data
dex.swap(myToken, amount, targetToken);
}Good:
function lzCompose(address _from, ...) external {
require(msg.sender == address(endpoint), "Only endpoint");
require(_from == address(myOFT), "Only my OFT");
dex.swap(myToken, amount, targetToken);
}Without both checks, an attacker can call your compose function directly with arbitrary data.
Next
- Part 6: Developer Gotchas — Common mistakes and how to avoid them