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
- Familiarity with Anchor and Solana CPIs.
- Read the conceptual guide: Omnichain Composers
- Understanding of Solana OApps and the PDA used
- Understanding of the
lz_receivev2 flow (see lz_receive_types_v2)
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:
- Sending Application: The sender OApp sends a message with a
composeMsgattached. For composed messages to Solana, the recipient address should be set to the Composer PDA. - Receiving Application: The destination OApp's
lz_receiveis executed, and since there is acomposeMsg, asend_composeCPI is made to the Endpoint program to send the compose message to the Composer PDA. - Composer Application: The Executor calls
lz_composeon 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 forlz_composeexecution, and more importantly, whose address is used as the 'Composer address'LzComposeTypesAccounts- a PDA that contains the accounts needed to calllz_compose_typeslz_compose_types_info- an instruction that provides versioning info that helps the Executor understand how to proceed withlz_compose_types/lz_compose_types_v2lz_compose_types_v2- an instruction that returns the list of accounts and execution plan needed to executelz_composelz_compose- the instruction that contains the actual business logic that must be executed for acomposeMsg
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
initinstruction that was already responsible for initializinglz_receive_types_accountsis extended to also initializelz_compose_types_accounts - The
initfunction 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,
¶ms.from,
&ctx.accounts.composer.key(),
¶ms.guid,
params.index,
¶ms.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 thecomposeMsg._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(¶ms.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_typesandlz_compose_typesPDAs are initialized and kept in sync with your handlers. - Always call
Endpoint::clearbefore touching user state inlz_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 oflz_compose_types) vs execution handler (lz_compose) expectations.
Next steps
- Review the Solana OApp Reference for flows and PDAs.
- See EVM counterpart: Omnichain Composers (EVM) for conceptual parity.