Contents

LayerZero V2 Part 5: Composed Messages

This is Part 5 of a series on LayerZero V2. Part 1 covers the basics.

/images/part5-composed-messages.svg

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 transaction

If 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 payload

Use 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 logic

The 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 ETH

Security: 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