Skip to main content
Version: Endpoint V2

Solana Composers

Cross-chain composability enables multi-step workflows that span multiple chains. LayerZero V2 supports composing follow-up actions as separate messages, improving reliability and flexibility for complex flows.

This page explains how to implement composability for Solana programs using the lz_compose_types_v2 typed discovery flow.

Prerequisites

Why composability matters

For a clear, high-level explanation of both the how and the why behind composed messaging, see the conceptual guide: Omnichain Composers.

How composability works

A message may or may not contain a compose message. When it does, we refer to it as a composed message. The following is the workflow for a composed message when the destination chain is Solana.

A composed flow is split into distinct steps across messages:

  1. Sending Application: The sender OApp sends a message with a composeMsg attached. For composed messages to Solana, the recipient address should be set to the Composer PDA.
  2. Receiving Application: The destination OApp's lz_receive is executed, and since there is a composeMsg, a send_compose CPI is made to the Endpoint program to send the compose message to the Composer PDA.
  3. Composer Application: The Executor calls lz_compose on the Composer Program (the program that owns the Composer PDA).

A Composer is the smart contract that is responsible for executing a compose message.

This separation reduces call-stack complexity and allows non-critical reverts in the composed step without rolling back the initial receive.

Installation

You can either extend your existing OApp or OFT program to turn it into a Composer, or create a standalone program to serve as the Composer.

In this example, we will scaffold a basic Solana OApp, and extend it to also be a Composer.

Use the CLI to scaffold a Solana OApp project you can extend with compose:

LZ_ENABLE_SOLANA_OAPP_EXAMPLE=1 npx create-lz-oapp@latest --example oapp-solana

Usage

The accounts and instructions needed are similar to those outlined in lz_receive_types_v2:

  • composer - a PDA that can be used to store static addresses needed for lz_compose execution, and more importantly, whose address is used as the 'Composer address'
  • LzComposeTypesAccounts - a PDA that contains the accounts needed to call lz_compose_types
  • lz_compose_types_info- an instruction that provides versioning info that helps the Executor understand how to proceed with lz_compose_types/lz_compose_types_v2
  • lz_compose_types_v2 - an instruction that returns the list of accounts and execution plan needed to execute lz_compose
  • lz_compose - the instruction that contains the actual business logic that must be executed for a composeMsg

lz_compose_types_v2

lz_compose_types_v2 achieves similar goals to lz_receive_types_v2 (supports more accounts, multiple instructions, multiple signers) but for compose messages. The flow is similar: discover versioned accounts via lz_compose_types_info, return a compact, ALT-aware execution plan via lz_compose_types_v2, then the Executor builds and submits the transaction that includes the lz_compose instruction.

Note that for this example, we will assume that the Composer is integrated into the OApp program itself. Whether you adopt this design as well depends on your use case. You may also choose to have a standalone Composer program.

Implementing lz_compose_types_v2

Composer PDA

Define the composer PDA seed in your program's lib.rs:

const COMPOSER_SEED: &[u8] = b"Composer";

Define the struct of the PDA that will hold the static addresses or fields that will be used in lz_compose_types later:

#[account]
pub struct Composer {
pub endpoint_program: Pubkey,
// if you need to namespace your Composer PDA, you can add the identifier here
// you can add other fields as needed
pub bump: u8,
}

impl Composer {
// 8 (discriminator) + 1 Pubkey + 1 bump
pub const SIZE: usize = 8 + 1 * 32 + 1;
}

LzComposeTypesAccounts

Define the struct of PDA that holds versioned compose-type discovery data, e.g. LzComposeTypesAccounts:

/// LzComposeTypesAccounts includes accounts that are used in the LzComposeTypesV2 instruction.
#[account]
#[derive(InitSpace)]
pub struct LzComposeTypesAccounts {
pub composer: Pubkey, // Note: The Composer PDA.
// Example: You can also store a single ALT (or change to Vec<Pubkey> for many)
pub alt: Pubkey,
// You may add more Pubkeys here per your use case
pub bump: u8,
}

Initialize the LzComposeTypesAccounts PDA in your init instruction:

use crate::{
state::LzComposeTypesAccounts,
STORE_SEED,
};
use anchor_lang::prelude::*;
use anchor_lang::solana_program::address_lookup_table::program::ID as ALT_PROGRAM_ID;
use oapp::{
endpoint::{instructions::RegisterOAppParams},
LZ_COMPOSE_TYPES_SEED,
};

#[derive(Accounts)]
pub struct Init<'info> {
// .. existing accounts including payer, store, lz_receive_types_accounts and its ALT
/// PDA holding all the static pubkeys for lz_compose execution
#[account(
init,
payer = payer,
space = Composer::SIZE,
seeds = [COMPOSER_SEED], // Note: if the composer needs to be namespaced, add the identifier into the seed here
bump
)]
pub composer: Account<'info, Composer>,
#[account(
init,
payer = payer,
space = 8 + LzComposeTypesAccounts::INIT_SPACE,
seeds = [LZ_COMPOSE_TYPES_SEED, composer.key().as_ref()],
bump
)]
pub lz_compose_types_accounts: Account<'info, LzComposeTypesAccounts>,
// Note: For simplicity, we will use the same ALT for both lz_receive_types_accounts and lz_compose_types_accounts, but you can also accept two separate ALTs if you want the ability to specify them separately.
// If you choose to specify them separately, then you can name them something like lz_receive_alt and lz_compose_alt, and just modify the instruction handler below to use the right account keys
#[account(owner = ALT_PROGRAM_ID)]
pub alt: Option<UncheckedAccount<'info>>, // optional.
pub system_program: Program<'info, System>,
}

impl Init<'_> {
pub fn apply(ctx: &mut Context<Init>, params: &InitParams) -> Result<()> {
// existing code
ctx.accounts.composer.endpoint_program = params.endpoint_program;
ctx.accounts.composer.bump = ctx.bumps.composer;

ctx.accounts.lz_compose_types_accounts.composer = ctx.accounts.composer.key();
ctx.accounts.lz_compose_types_accounts.alt = ctx.accounts.alt.as_ref().map(|a| a.key()).unwrap_or_default();
ctx.accounts.lz_compose_types_accounts.bump = ctx.bumps.lz_compose_types_accounts;
// existing code
}
}
  • If you need the Composer PDA to be namespaced by a certain identifier, you can add it into its seeds. The convention is to also store that value in the Composer PDA itself, which requires amending its struct that was defined earlier.
  • The above assumes that the existing init instruction that was already responsible for initializing lz_receive_types_accounts is extended to also initialize lz_compose_types_accounts
  • The init function can be arbitrarily named, as long as it is called

lz_compose_types_info

Create an lz_compose_types_info instruction that returns the version and the accounts needed to construct lz_compose_types_v2:

use oapp::{
lz_compose_types_v2::{LzComposeTypesV2Accounts, LZ_COMPOSE_TYPES_VERSION},
LzComposeParams, LZ_COMPOSE_TYPES_SEED,
};

use crate::*;

#[derive(Accounts)]
pub struct LzComposeTypesInfo<'info> {
#[account(seeds = [COMPOSER_SEED], bump = composer.bump)]
pub composer: Account<'info, Composer>,
#[account(seeds = [LZ_COMPOSE_TYPES_SEED, composer.key().as_ref()], bump = lz_compose_types_accounts.bump)]
pub lz_compose_types_accounts: Account<'info, LzComposeTypesAccounts>,
}

impl LzComposeTypesInfo<'_> {
/// Returns (version, versioned_data) used by the Executor
pub fn apply(
ctx: &Context<LzComposeTypesInfo>,
_params: &LzComposeParams,
) -> Result<(u8, LzComposeTypesV2Accounts)> {
let composer = &ctx.accounts.composer;
let compose_types_account = &ctx.accounts.lz_compose_types_accounts;
let required_accounts = if compose_types_account.alt == Pubkey::default() {
vec![
// 1) composer PDA
compose_types_account.composer
]
} else {
vec![
// 1) composer PDA
compose_types_account.composer,
// ALT will be passed under remaining_accounts
compose_types_account.alt
]
};
Ok((LZ_COMPOSE_TYPES_VERSION, LzComposeTypesV2Accounts { accounts: required_accounts }))
}
}

lz_compose_types_v2

Implement lz_compose_types_v2 and return a compact execution plan including exactly one Instruction::LzCompose:

use crate::*;
use oapp::{
common::{compact_accounts_with_alts, AccountMetaRef, AddressLocator, EXECUTION_CONTEXT_VERSION_1},
lz_compose_types_v2::{get_accounts_for_clear_compose, Instruction, LzComposeTypesV2Result},
LzComposeParams,
};

#[derive(Accounts)]
#[instruction(params: LzComposeParams)]
pub struct LzComposeTypesV2<'info> {
// 1) Composer PDA
#[account(seeds = [COMPOSER_SEED], bump = composer.bump)]
pub composer: Account<'info, Composer>,
}

impl LzComposeTypesV2<'_> {
/// Returns the execution plan for lz_compose with a minimal account set.
pub fn apply(
ctx: &Context<LzComposeTypesV2>,
params: &LzComposeParams,
) -> Result<LzComposeTypesV2Result> {
let mut accounts = vec![
// 0) payer
AccountMetaRef { pubkey: AddressLocator::Payer, is_writable: true },
// 1) endpoint program
AccountMetaRef { pubkey: ctx.accounts.composer.endpoint_program, is_writable: false },
// 2) composer PDA
AccountMetaRef { pubkey: ctx.accounts.composer.key().into(), is_writable: false },
];

// Endpoint helper accounts for compose
let accounts_for_composing = get_accounts_for_clear_compose(
ctx.accounts.composer.endpoint_program,
&params.from,
&ctx.accounts.composer.key(),
&params.guid,
params.index,
&params.message,
);
accounts.extend(accounts_for_composing);

Ok(LzComposeTypesV2Result {
context_version: EXECUTION_CONTEXT_VERSION_1,
alts: ctx.remaining_accounts.iter().map(|alt| alt.key()).collect(),
instructions: vec![
Instruction::LzCompose {
// In this example, ALTs are passed in via remaining_accounts
// This decision allows for flexibility in terms of passing in any number of ALTs without needing to change the accounts struct
// However, if you need stronger schema guarantees and require only a single ALT, you may opt to have it passed in explicitly via ctx.accounts.alt (or similar)
accounts: compact_accounts_with_alts(&ctx.remaining_accounts, accounts)?,
},
],
})
}
}

Composed Message Execution Options

Longer composed flows increase the cost of executing lz_receive on the destination and the follow-up lz_compose call. You must set sufficient gas and value in your Message Options for both steps.

  • Add extra gas for the lzReceive step:
// addExecutorLzReceiveOption(uint128 _gas, uint128 _value)
Options.newOptions().addExecutorLzReceiveOption(50000, 0);
  • Also set gas/value for the composer execution:
// addExecutorLzComposeOption(uint16 _index, uint128 _gas, uint128 _value)
Options.newOptions().addExecutorLzReceiveOption(50000, 0).addExecutorLzComposeOption(0, 30000, 0);

Parameters:

  • _index: the index of the composeMsg.
  • _gas: gas/compute budget for the destination execution.
  • _value: native value forwarded with the call if needed.

If insufficient limits are provided, execution will not proceed and a manual retry with higher limits will be required.

For an overview of how options work, see Message Options.

Composing an OFT

Sending an OFT from Solana with a compose message

The OFT program (Solana) supports attaching an optional compose_msg to a cross-chain send.

pub struct SendParams {
pub dst_eid: u32,
pub to: [u8; 32],
pub amount_ld: u64,
pub min_amount_ld: u64,
pub options: Vec<u8>,
pub compose_msg: Option<Vec<u8>>, // <---- Optional compose_msg param
pub native_fee: u64,
pub lz_token_fee: u64,
}

Handling a compose message for an OFT on Solana

The reference Solana OFT program's lz_receive already includes a call to Endpoint::send_composefor when a message contains a compose_msg :

if let Some(message) = msg_codec::compose_msg(&params.message) {
oapp::endpoint_cpi::send_compose(
ctx.accounts.oft_store.endpoint_program,
ctx.accounts.oft_store.key(),
&ctx.remaining_accounts[Clear::MIN_ACCOUNTS_LEN..],
seeds,
SendComposeParams {
to: ctx.accounts.to_address.key(),
guid: params.guid,
index: 0, // only 1 compose msg per lzReceive
message: compose_msg_codec::encode(
params.nonce,
params.src_eid,
amount_received_ld,
&message,
),
},
)?;
}

The OFT Program comes with a default compose_msg_codec that's used for sending and receiving composed messages:

const NONCE_OFFSET: usize = 0;
const SRC_EID_OFFSET: usize = 8;
const AMOUNT_LD_OFFSET: usize = 12;
const COMPOSE_FROM_OFFSET: usize = 20;
const COMPOSE_MSG_OFFSET: usize = 52;

pub fn encode(
nonce: u64,
src_eid: u32,
amount_ld: u64,
compose_msg: &Vec<u8>, // [composeFrom][composeMsg]
) -> Vec<u8> {
let mut encoded = Vec::with_capacity(20 + compose_msg.len()); // 8 + 4 + 8
encoded.extend_from_slice(&nonce.to_be_bytes());
encoded.extend_from_slice(&src_eid.to_be_bytes());
encoded.extend_from_slice(&amount_ld.to_be_bytes());
encoded.extend_from_slice(&compose_msg);
encoded
}

pub fn nonce(message: &[u8]) -> u64 {
let mut nonce_bytes = [0; 8];
nonce_bytes.copy_from_slice(&message[NONCE_OFFSET..SRC_EID_OFFSET]);
u64::from_be_bytes(nonce_bytes)
}

pub fn src_eid(message: &[u8]) -> u32 {
let mut src_eid_bytes = [0; 4];
src_eid_bytes.copy_from_slice(&message[SRC_EID_OFFSET..AMOUNT_LD_OFFSET]);
u32::from_be_bytes(src_eid_bytes)
}

pub fn amount_ld(message: &[u8]) -> u64 {
let mut amount_ld_bytes = [0; 8];
amount_ld_bytes.copy_from_slice(&message[AMOUNT_LD_OFFSET..COMPOSE_FROM_OFFSET]);
u64::from_be_bytes(amount_ld_bytes)
}

pub fn compose_from(message: &[u8]) -> [u8; 32] {
let mut compose_from = [0; 32];
compose_from.copy_from_slice(&message[COMPOSE_FROM_OFFSET..COMPOSE_MSG_OFFSET]);
compose_from
}

pub fn compose_msg(message: &[u8]) -> Vec<u8> {
if message.len() > COMPOSE_MSG_OFFSET {
message[COMPOSE_MSG_OFFSET..].to_vec()
} else {
Vec::new()
}
}

The reference Solana OFT program, however, does not include a dedicated lz_compose handler as the decision of whether to extend the OFT program or have a separate Composer program is for the developer to decide.

If your product needs compose flows, use lz_compose_types_v2 to describe execution and choose one of these patterns:

  • Create a separate composer program (recommended for separation of concerns).
  • Modify the OFT program to also act as the composer (tighter coupling).

Composing an OApp

As shown above in "Composing an OFT", the send_compose call is issued from within lz_receive after Endpoint::clear. Custom OApps follow the same pattern: detect a compose payload, then forward it via send_compose to your Composer PDA (or destination PDA). Ensure your discovery accounts stay in sync with the execution plan described by lz_compose_types_v2. For general receive flow details, see OApp lz_receive — business logic + Endpoint::clear.

Execution notes and troubleshooting

  • Ensure lz_receive_types and lz_compose_types PDAs are initialized and kept in sync with your handlers.
  • Always call Endpoint::clear before touching user state in lz_receive.
  • The Executor enforces a fee-limited execution context; keep instruction counts and compute within limits.
  • If account ordering mismatches, you may see AccountNotWritable/InvalidProgramId—double-check discovery (return of lz_compose_types) vs execution handler (lz_compose) expectations.

Next steps