LayerZero V2 Solana OApp Reference
The OApp Standard provides developers with a generic message passing interface to send and receive arbitrary pieces of data between contracts existing on different blockchain networks.
How exactly the data is interpreted and what other actions they trigger, depend on the specific OApp implementation.
Quickstart
Example
For the step-by-step instructions on how to build, deploy and wire a Solana OApp, view the Solana OApp example.
Scaffold
Spin up a new Solana OApp project (based on the example) in seconds:
LZ_ENABLE_SOLANA_OAPP_EXAMPLE=1 npx create-lz-oapp@latest
Specify the directory, select OApp (Solana) and proceed with the installation.
The example contains a string-passing OApp that works across Solana and EVM.
Follow the provided README instructions to deploy the example and make your first cross-chain message between Solana and an EVM chain.
The following sections will highlight the several code excerpts of the Solana OApp that are essential to it functioning.
Developing Solana OApps vs EVM OApps
Due to VM and programming paradigm differences, developing OApps on Solana works differently from doing the same on EVM chains.
On EVM chains, OApps can simply inherit the OApp contract standard to unlock cross-chain functionality. On Solana, there is no similar inheritance.
The table below outlines the main differences when developing:
| EVM | Solana |
|---|---|
OApp contracts inherit base contracts such as OAppSender and OAppReceiver which hides all endpoint calls. | No inheritance. Your OApp program must directly include the code that CPIs the endpoint. |
Dynamic dispatch ⇒ endpoint.lzReceive() can delegatecall back into your contract without pre-knowing storage slots. | All accounts required for execution of lz_receive() must be listed up-front. This is done via the Executor calling the lz_receive_types instruction. |
| The OApp contract's address is used as the OApp address | The OApp program's address is not used as the OApp address. Instead, a PDA owned by the OApp program is used as the OApp address. For example, for OFTs, the OApp address is the OFT Store's address, which is owned by the OFT program. |
| Pathway configs are set in the storage of the Endpoint contract. | Pathway configs are stored in PDAs owned by the Endpoint program or the Send/Library program. |
High-level message flow
┌────────┐ (1) msg packet to receiver PDA
EVM │ Src │ ──────────────────────────────────────────► Solana
Chain │ OApp │ │
└────────┘ ▼
┌─────────────────────────┐
│ Executor program │
└─────────────────────────┘
│
(2) CPI: `lz_receive_types` ──────┘ ← returns Vec<LzAccount>
│ (accounts required for `lz_receive`)
(3) CPI: `lz_receive` (your code) |
- expects full list of required accounts │
– runs business logic │
– CPIs back into Endpoint (`Endpoint::clear()`) │
│
▼
┌────────┐
│ Dst │
│ OApp │
└────────┘
(4) Endpoint and OApp state updated
Required PDAs
The following are the PDAs that are required for a Solana OApp.
| Component | What it is | Seed / Derivation | Why it matters |
|---|---|---|---|
| OApp Store PDA | Zero-copy account holding program state (admin, bump, endpoint id, user data …) | [b"Store"] (customizable) | Acts as receiver address and signer seed for Endpoint CPIs |
| Peer PDA(s) | One per remote chain; stores the peer address allowed to send | [b"Peer", store, src_eid] | Used to authenticate params.sender inside lz_receive |
| lz_receive_types PDA | Flat list of static accounts you’ll echo back | [b"LzReceiveTypes", store] | Queried off-chain; never touched on-chain |
| lz_compose_types PDA | Same pattern but for compose messages | [b"LzComposeTypes", store] | Only needed if you compose sub-messages |
Note that except for the Store PDA, the PDA seeds are not customizable.
Initialize the OApp PDA
This init_store instruction initializes 3 PDAs: OApp Store's, lz_receive_types and lz_compose_types.
impl InitStore<'_> {
pub fn apply(ctx: &mut Context<InitStore>, params: &InitStoreParams) -> Result<()> {
ctx.accounts.store.admin = params.admin;
ctx.accounts.store.bump = ctx.bumps.store;
ctx.accounts.store.endpoint_program = params.endpoint;
ctx.accounts.store.string = "Nothing received yet.".to_string();
// set up the “types” PDAs so the SDK can find them
ctx.accounts.lz_receive_types_accounts.store = ctx.accounts.store.key();
ctx.accounts.lz_compose_types_accounts.store = ctx.accounts.store.key(); // note that the current Solana OApp example does not yet fully implement lzCompose
// calling endpoint cpi
let register_params = RegisterOAppParams { delegate: ctx.accounts.store.admin };
let seeds: &[&[u8]] = &[STORE_SEED, &[ctx.accounts.store.bump]];
// ▸ Register with the Endpoint so the Executor can call us later
oapp::endpoint_cpi::register_oapp(
ENDPOINT_ID,
ctx.accounts.store.key(),
ctx.remaining_accounts,
seeds,
register_params,
)?;
Ok(())
}
}
Key points
- You must call
oapp::endpoint_cpi::register_oappto register your OApp - Registration is one-time; afterwards the Executor knows this Store PDA = OApp.
- You do not pass a “trusted remote” mapping here—that’s what the
PeerPDAs enforce. - The
stringfield is specific to the string-passing OApp example - You can extend your OApp's store PDA with other fields required by your use case.
lz_receive_types — tell the Executor which accounts are needed by lz_receive
When the Executor calls lz_receive on any OApp program, due to how Solana works, it needs to provide a list of all the accounts that are read or written to. The Solana OApp standard does this via the lz_receive_types instruction, shown in the example excerpt below.
pub fn apply(
ctx: &Context<LzReceiveTypes>,
params: &LzReceiveParams,
) -> Result<Vec<LzAccount>> {
// 1. your writable state
let store = ctx.accounts.store.key();
// 2. the peer that sent the message (read-only)
let peer_seeds = [PEER_SEED, &store.to_bytes(), ¶ms.src_eid.to_be_bytes()];
let (peer, _) = Pubkey::find_program_address(&peer_seeds, ctx.program_id);
let mut accs = vec![
LzAccount { pubkey: store, is_signer: false, is_writable: true },
LzAccount { pubkey: peer, is_signer: false, is_writable: false },
];
// 3. Accounts specifically required for calling Endpoint::clear()
accs.extend(get_accounts_for_clear(
ENDPOINT_ID,
&store,
params.src_eid,
¶ms.sender,
params.nonce,
));
// 4. (optional) If the message itself is a *compose* payload,
// also append the accounts the Endpoint expects for send_compose()
if msg_codec::msg_type(¶ms.message) == msg_codec::COMPOSED_TYPE {
accs.extend(get_accounts_for_send_compose(
ENDPOINT_ID,
&store, // payer = this PDA
&store, // receiver (self-compose)
¶ms.guid,
0, // fee = 0, Executor pre-funds
¶ms.message,
));
}
Ok(accs)
}
Rules of thumb
- Exact order matters—
LzAccount[0]in the Vec becomes account #0 in the eventual tx. - Signer placeholders: If some downstream CPI (e.g. ATA init) needs a signer, pass
pubkey = Pubkey::default(), is_signer = true. - Include every account
lz_receiveand_ your Endpoint CPIs will require.
Accounts for lz_receive_types checklist
For a minimal string-passing OApp, these are the accounts that must be returned by lz_receive_types:
0 store (w) – PDA signer via seeds [b"Store"]
1 peer (r) – verifies src sender
2 endpoint_program – oapp::endpoint (ID)
3 system_program – for rent in clear()
4 rent sysvar
5 … 10 – (six replay-protection PDAs that get_accounts_for_clear puts at the end)
Your lz_receive_types must return these exact pubkeys in this exact order or the Executor
will splat with “AccountNotWritable”, “InvalidProgramId”, etc.
Note that the accounts that lz_receive_types return will differ by program. The accounts listed above are specific to the string-passing OApp program. The lz_receive_types of the OFT program will require a different list of accounts.
lz_receive — business logic + Endpoint::clear
The lz_receive instruction is where your OApp's business logic is defined and also where the call to Endpoint::clear is made. Additionally, it is also where compose messages are handled, if implemented.
#[derive(Accounts)]
#[instruction(params: LzReceiveParams)]
pub struct LzReceive<'info> {
#[account(mut, seeds = [STORE_SEED], bump = store.bump)]
pub store: Account<'info, Store>,
#[account(
seeds = [PEER_SEED, store.key().to_bytes(), ¶ms.src_eid.to_be_bytes()],
bump = peer.bump,
constraint = params.sender == peer.peer_address
)]
pub peer: Account<'info, PeerConfig>
}
pub fn apply(ctx: &mut Context<LzReceive>, params: &LzReceiveParams) -> Result<()> {
// 1. replay-protection (handled inside clear)
let seeds = &[STORE_SEED, &[ctx.accounts.store.bump]];
// The first Clear::MIN_ACCOUNTS_LEN remaining accounts are exactly what
// get_accounts_for_clear() returned earlier.
let clear_accounts = &ctx.remaining_accounts[..Clear::MIN_ACCOUNTS_LEN];
oapp::endpoint_cpi::clear(
ENDPOINT_ID,
ctx.accounts.store.key(), // payer (seeds above)
clear_accounts,
seeds,
ClearParams {
receiver: ctx.accounts.store.key(),
src_eid: params.src_eid,
sender: params.sender,
nonce: params.nonce,
guid: params.guid,
message: params.message.clone(),
},
)?;
// You should have app-specific logic to determine whether a message is a compose message
// e.g. you can have part of the payload be a u8 where 1 = regular message, 2 = compose message
// or, especially if your regular payload has a fixed length, determine the presence of a compose message based on presence of data after an offset (example: https://github.com/LayerZero-Labs/devtools/blob/main/examples/lzapp-migration/programs/oft202/src/msg_codec.rs#L60)
oapp::endpoint_cpi::send_compose(
ENDPOINT_ID,
ctx.accounts.store.key(),
&ctx.remaining_accounts[Clear::MIN_ACCOUNTS_LEN..],
seeds,
SendComposeParams {
to: ctx.accounts.store.key(), // self
guid: params.guid,
index: 0,
message: params.message.clone(),
},
)?;
// 2. Your app-specific logic
let new_string = msg_codec::decode(¶ms.message);
ctx.accounts.store.string = new_string;
Ok(())
}
Rules of thumb
- Call
clear()before touching any user state—this burns the nonce and prevents re-entry. - Use
ctx.remaining_accountsinstead of hard-wiring anything—keepslz_receive_typesandlz_receiveperfectly in sync. - Don’t forget
is_signer: truezero-pubkey placeholders for ATA init or rent payer.
Security Reminders
- Validate the
Peeraccount first (constraint = params.sender == peer.address). - Store the Endpoint ID inside state (
store.endpoint_program) and assert it every CPI.
OApp-Specific Message Codec
Since at its core, the OApp Standard simply gives you the interface for generic message passing (raw bytes), you need to implement for yourself how the raw bytes are interpreted.
In the Solana OApp example and also in the OFT implementation, this logic is encapusalated in a msg_codec.rs file.
use anchor_lang::prelude::error_code;
use std::str;
// Just like OFT, we don't need an explicit MSG_TYPE param
// Instead, we'll check whether there's data after the string ends
pub const LENGTH_OFFSET: usize = 0;
pub const STRING_OFFSET: usize = 32;
#[error_code]
pub enum MsgCodecError {
/// Buffer too short to even contain the 32‐byte length header
InvalidLength,
/// Header says "string is N bytes" but buffer < 32+N
BodyTooShort,
/// Payload bytes aren’t valid UTF-8
InvalidUtf8,
}
fn decode_string_len(buf: &[u8]) -> Result<usize, MsgCodecError> {
if buf.len() < STRING_OFFSET {
return Err(MsgCodecError::InvalidLength);
}
let mut string_len_bytes = [0u8;32];
string_len_bytes.copy_from_slice(&buf[LENGTH_OFFSET..LENGTH_OFFSET+32]);
Ok(u32::from_be_bytes(string_len_bytes[28..32].try_into().unwrap()) as usize)
}
pub fn encode(string: &str) -> Vec<u8> {
let string_bytes = string.as_bytes();
let mut msg = Vec::with_capacity(
STRING_OFFSET + // length word (fixed)
string_bytes.len() // string length
);
// 4-byte length
msg.extend(std::iter::repeat(0).take(28)); // padding
msg.extend_from_slice(&(string_bytes.len() as u32).to_be_bytes());
// string
msg.extend_from_slice(string_bytes);
msg
}
pub fn decode(message: &[u8]) -> Result<String, MsgCodecError> {
// Read the declared payload length from the header
let string_len = decode_string_len(message)?;
let start = STRING_OFFSET;
// Safely compute end index and check for overflow
let end = start
.checked_add(string_len)
.ok_or(MsgCodecError::InvalidLength)?;
// Ensure the buffer actually contains the full payload
if end > message.len() {
return Err(MsgCodecError::BodyTooShort);
}
// Slice out the payload bytes
let payload = &message[start..end];
// Attempt to convert to &str, returning an error if invalid UTF-8
match str::from_utf8(payload) {
Ok(s) => Ok(s.to_string()),
Err(_) => Err(MsgCodecError::InvalidUtf8),
}
}
Key points
- Every OApp would have its own Message Codec implementation
- The above Message Codec example involves an OApp that expects the message to contain only a
lengthand the actualstring - If sending across VMs, ensure the codec on the other VM matches.
lz_receive_types_v2
For simpler use cases, lz_receive_types (v1) is sufficient. However, for more complex use cases, lz_receive_types (v1) has several limitations.
Limitations of lz_receive_types (v1)
1. Return Data Size Constraint
Solana’s runtime restricts the return data of CPI calls to a maximum of 1024 bytes. This constrains the number of accounts that lz_receive_types(v1) can return to roughly 30—insufficient for complex messaging patterns such as the ABA messaging pattern.
2. Lack of Address Lookup Tables Support
lz_receive_types (v1) does not leverage Solana’s Address Lookup Tables (ALTs), which extend the maximum number of accounts in a transaction from 32 to 128. This restricts functionality of state-heavy applications.
3. Lack of ABA Messaging Pattern Support
lz_receive_types (v1) does not support the ABA messaging pattern (where the destination OApp sends a new LayerZero message during lz_receive). This limitation is due to:
- No
lz_sendaccount discovery: OApps are forced to freeze send-related configurations and declare all associated accounts within lz_receive_types(). - CPI depth limit:
lz_receiveexecuted via Cross-Program Invocation (CPI), encounters a limitation due to Solana's maximum CPI depth of 4. This depth is fully utilized by ULN-basedlz_sendoperations, making ABA messaging infeasible.
4. No support for Multiple Instructions
lz_receive_types (v1) supports only a single-instruction execution model, but on Solana composing program behavior through multiple instructions is a common pattern, especially to work around the CPI depth limit of 4.
5. No Support for EOA Account Initialization
The Executor provides only a single signer as the payer for account rent. lz_receive_types (v1) does not support passing in additional EOAs as signers to create arbitrary writable data accounts on demand, which limits account creation and flexibility in state management.
6. OApp-Unaware Executor Fee Limit
While the Executor enforces an internal limit on how much SOL can be spent from its signer account, the OApp has no visibility into this threshold. This lack of transparency makes it difficult for OApps to reason about safe spending behavior, increasing the risk of unintentionally exceeding the limit and triggering transaction failure.
What lz_receive_types_v2 introduces
lz_receive_types_v2 addresses the limitations of lz_receive_types (v1) through the following key features:
- Support for multiple ALTs: Expands the capacity for account inclusion by allowing multiple Address Lookup Tables, increasing the number of accounts accessible within a transaction.
- Compact and flexible account reference model: Implements the AddressLocator, enabling OApps to reference accounts with a leaner, more efficient structure while maintaining adaptability for future upgrades.
- Context account from the Executor: Provides runtime metadata at execution, ensuring that OApps have contextual awareness without requiring redundant account fetching or manual setup. In the code, this is referred to as the ExecutionContext.
- Explicit support for multiple EOA signers: Enables dynamic data account initialization by allowing multiple externally owned accounts (EOAs) to participate in signing and setup. This improves flexibility for multi-party or multi-step workflows.
- Multi-instruction execution model: Empowers OApps to compose complex workflows within a single atomic transaction, combining several instructions while preserving consistency and rollback guarantees.
How lz_receive_types_v2 works
Overall, lz_receive_types_v2 has the following execution flow:
OAppAccount
LzReceiveTypesV2Accounts
|
(1) lz_receive_types_info
|
v
(2) lz_receive_types_v2
|
| Full list of instructions for lz_receive + ALTs
v
(3) build and submit transaction (including lz_receive)
lz_receive_types_info- requires two accounts in this exact order (must not be changed):
oapp_account- the OApp identity/account. In the code examples here, theoapp_accountis thestoreaccount.lz_receive_types_accounts- PDA derived withseeds = [LZ_RECEIVE_TYPES_SEED, &oapp_account.key().to_bytes()].
- returns
(version, versioned_data)version: u8— A protocol-defined version identifier for theLzReceiveTypelogic and return type, starting from 2.versioned_data: Any— A value of typeAny, representing a version-specific structure. The Executor decodes this payload based on the version and uses it to construct the full set of accounts needed to invokelz_receive_types_v2(LzReceiveTypesV2Accounts).
- requires two accounts in this exact order (must not be changed):
lz_receive_types_v2- this instruction is called withLzReceiveTypesV2Accountsas the supplied accounts and returns:- the
context_version - ALTs used (if any)
- The full list of instructions for
lz_receive
- the
build and submit transaction- now the Executor can prepare the full transaction and submit it based on what was returned bylz_receive_types_v2. Before submitting, the Executor will also prepend (pre_execute) and append (post_execute) instructions to prepare the execution context and ensure safety:- pre_execute - Initializes a fee-limited execution context at the start of the transaction (allowing up to two ComputeBudget instructions), records the payer’s starting balance, and ensures a matching PostExecute will end the transaction.
- post_execute - validates pairing with PreExecute, enforces the fee limit against the payer’s balance change, checks signer invariants, and resets the context.
All the instructions above are called by the Executor. As the OApp developer, you only need to ensure that you implement the lz_receive_types_v2 interface in your OApp program.
Implementing lz_receive_types_v2
Define LzReceiveTypesAccounts anywhere in your state module tree:
/// LzReceiveTypesAccounts includes accounts that are used in the LzReceiveTypes instruction.
#[account]
#[derive(InitSpace)]
pub struct LzReceiveTypesAccounts {
pub store: Pubkey, // Note: This is used as your OApp address.
pub alt: Pubkey, // Note: in this example, we store a single ALT. You can modify this to store a Vec of Pubkeys too.
pub bump: u8,
// Note: you may add more account Pubkeys into this struct, per your use case.
}
storehere is the OApp account referred to asoapp_accountin the flow description.
Ensure that you init the LzReceiveTypesAccounts PDA in your init instruction:
use crate::{
state::{LzReceiveTypesAccounts},
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, ID as ENDPOINT_ID},
LZ_RECEIVE_TYPES_SEED,
};
#[derive(Accounts)]
pub struct Init<'info> {
#[account(mut)]
pub payer: Signer<'info>,
#[account(
init,
payer = payer,
space = 8 + Store::INIT_SPACE,
seeds = [STORE_SEED],
bump
)]
pub store: Account<'info, Store>,
#[account(
init,
payer = payer,
space = 8 + LzReceiveTypesAccounts::INIT_SPACE,
seeds = [LZ_RECEIVE_TYPES_SEED, store.key().as_ref()],
bump
)]
pub lz_receive_types_accounts: Account<'info, LzReceiveTypesAccounts>,
#[account(owner = ALT_PROGRAM_ID)]
pub alt: Option<UncheckedAccount<'info>>,
pub system_program: Program<'info, System>,
}
impl Init<'_> {
pub fn apply(ctx: &mut Context<Init>, params: &InitParams) -> Result<()> {
ctx.accounts.store.endpoint_program =
if let Some(endpoint_program) = params.endpoint_program {
endpoint_program
} else {
ENDPOINT_ID
};
ctx.accounts.store.bump = ctx.bumps.store;
ctx.accounts.lz_receive_types_accounts.store = ctx.accounts.store.key();
// Set ALT if provided, otherwise default to Pubkey::default()
ctx.accounts.lz_receive_types_accounts.alt = ctx.accounts.alt.as_ref().map(|a| a.key()).unwrap_or_default();
ctx.accounts.lz_receive_types_accounts.bump = ctx.bumps.lz_receive_types_accounts;
let seeds: &[&[u8]] = &[STORE_SEED, &[ctx.accounts.store.bump]];
// Register the oapp
oapp::endpoint_cpi::register_oapp(
ctx.accounts.store.endpoint_program,
ctx.accounts.store.key(),
ctx.remaining_accounts,
seeds,
RegisterOAppParams { delegate: params.default_admin },
)?;
Ok(())
}
}
Create an lz_receive_types_info instruction:
use oapp::{
lz_receive_types_v2::{LzReceiveTypesV2Accounts, LZ_RECEIVE_TYPES_VERSION},
LzReceiveParams, LZ_RECEIVE_TYPES_SEED,
};
use crate::*;
/// LzReceiveTypesInfo instruction implements the versioning mechanism introduced in V2.
///
/// This instruction addresses the compatibility risk of the original LzReceiveType V1 design,
/// which lacked any formal versioning mechanism. The LzReceiveTypesInfo instruction allows
/// the Executor to determine how to interpret the structure of the data returned by
/// lz_receive_types() for different versions.
///
/// Returns (version, versioned_data):
/// - version: u8 — A protocol-defined version identifier for the LzReceiveType logic and return
/// type
/// - versioned_data: Any — A version-specific structure that the Executor decodes based on the
/// version
///
/// For Version 2, the versioned_data contains LzReceiveTypesV2Accounts which provides information
/// needed to construct the call to lz_receive_types_v2.
#[derive(Accounts)]
pub struct LzReceiveTypesInfo<'info> {
#[account(seeds = [STORE_SEED], bump = store.bump)]
pub store: Account<'info, Store>,
/// PDA account containing the versioned data structure for V2
/// Contains the accounts needed to construct lz_receive_types_v2 instruction
#[account(seeds = [LZ_RECEIVE_TYPES_SEED, store.key().to_bytes()], bump = lz_receive_types_accounts.bump)]
pub lz_receive_types_accounts: Account<'info, LzReceiveTypesAccounts>,
}
impl LzReceiveTypesInfo<'_> {
/// Returns the version and versioned data for LzReceiveTypes
///
/// Version Compatibility:
/// - Forward Compatibility: Executors must gracefully reject unknown versions
/// - Backward Compatibility: Version 1 OApps do not implement lz_receive_types_info; Executors
/// may fall back to assuming V1 if the version instruction is missing or unimplemented
///
/// For V2, returns:
/// - version: 2 (u8)
/// - versioned_data: LzReceiveTypesV2Accounts containing the accounts needed for
/// lz_receive_types_v2
pub fn apply(
ctx: &Context<LzReceiveTypesInfo>,
params: &LzReceiveParams,
) -> Result<(u8, LzReceiveTypesV2Accounts)> {
let receive_types_account = &ctx.accounts.lz_receive_types_accounts;
let required_accounts = if receive_types_account.alt == Pubkey::default() {
vec![
receive_types_account.store
// You can include more accounts here if necessary
]
} else {
vec![
receive_types_account.store,
receive_types_account.alt,
// You can include more accounts here if necessary
]
};
Ok((LZ_RECEIVE_TYPES_VERSION, LzReceiveTypesV2Accounts { accounts: required_accounts }))
}
}
Implement lz_receive_types_v2:
use crate::*;
use anchor_lang::solana_program;
use oapp::{
common::{
compact_accounts_with_alts, AccountMetaRef, AddressLocator, EXECUTION_CONTEXT_VERSION_1,
},
lz_receive_types_v2::{
Instruction, LzReceiveTypesV2Result,
},
LzReceiveParams,
};
#[derive(Accounts)]
#[instruction(params: LzReceiveParams)]
pub struct LzReceiveTypesV2<'info> {
#[account(seeds = [STORE_SEED], bump = store.bump)]
pub store: Account<'info, Store>,
// Note: include more accounts here if you had done so in the previous steps
}
impl LzReceiveTypesV2<'_> {
/// Returns the execution plan for lz_receive with a minimal account set.
pub fn apply(
ctx: &Context<LzReceiveTypesV2>,
params: &LzReceiveParams,
) -> Result<LzReceiveTypesV2Result> {
// Derive peer PDA from src_eid only (OFT key removed)
let peer_seeds = [PEER_SEED, ¶ms.src_eid.to_be_bytes()];
let (peer, _) = Pubkey::find_program_address(&peer_seeds, ctx.program_id);
// Event authority used for logging
let (event_authority_account, _) =
Pubkey::find_program_address(&[oapp::endpoint_cpi::EVENT_SEED], &ctx.program_id);
let accounts = vec![
// payer
AccountMetaRef { pubkey: AddressLocator::Payer, is_writable: true },
// peer
AccountMetaRef { pubkey: peer.into(), is_writable: false },
// event authority account - used for event logging
AccountMetaRef { pubkey: event_authority_account.into(), is_writable: false },
// system program
AccountMetaRef {
pubkey: solana_program::system_program::ID.into(),
is_writable: false,
},
// program id - the program that is executing this instruction
AccountMetaRef { pubkey: crate::ID.into(), is_writable: false },
];
// Return the execution plan (no clear/compose helper accounts)
Ok(LzReceiveTypesV2Result {
context_version: EXECUTION_CONTEXT_VERSION_1,
alts: ctx.remaining_accounts.iter().map(|alt| alt.key()).collect(),
instructions: vec![
Instruction::LzReceive {
// 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)?,
},
],
})
}
}
Ensure that you have registered the new instruction handlers in the program module in your lib.rs:
#[program]
pub mod my_oapp {
use super::*;
// ...the existing instructions
pub fn lz_receive_types_v2(
ctx: Context<LzReceiveTypesV2>,
params: LzReceiveParams,
) -> Result<LzReceiveTypesV2Result> {
LzReceiveTypesV2::apply(&ctx, ¶ms)
}
pub fn lz_receive_types_info(
ctx: Context<LzReceiveTypesInfo>,
params: LzReceiveParams,
) -> Result<(u8, LzReceiveTypesV2Accounts)> {
LzReceiveTypesInfo::apply(&ctx, ¶ms)
}
}
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
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 store: Pubkey, // Note: This is used as your OApp address.
pub alt: Pubkey, // Optionally store a single ALT (or change to Vec<Pubkey> for many)
pub bump: u8,
// You may add more Pubkeys here per your use case
}
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, ID as ENDPOINT_ID},
LZ_COMPOSE_TYPES_SEED,
};
#[derive(Accounts)]
pub struct Init<'info> {
// .. existing accounts including payer, store, lz_receive_types_accounts and its ALT
#[account(
init,
payer = payer,
space = 8 + LzComposeTypesAccounts::INIT_SPACE,
seeds = [LZ_COMPOSE_TYPES_SEED, store.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.lz_compose_types_accounts.store = ctx.accounts.store.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
}
}
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 = [STORE_SEED], bump = store.bump)]
pub store: Account<'info, Store>,
/// PDA containing the versioned data structure for V2
/// Derived using: seeds = [LZ_COMPOSE_TYPES_SEED, store.key().to_bytes()]
#[account(seeds = [LZ_COMPOSE_TYPES_SEED, store.key().to_bytes()], 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 compose_types_account = &ctx.accounts.lz_compose_types_accounts;
let required_accounts = if compose_types_account.alt == Pubkey::default() {
vec![
compose_types_account.store
]
} else {
vec![
compose_types_account.store,
compose_types_account.alt
]
};
Ok((LZ_COMPOSE_TYPES_VERSION, LzComposeTypesV2Accounts { accounts: required_accounts }))
}
}
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, EXECUTION_CONTEXT_VERSION_1},
endpoint::ID as ENDPOINT_ID,
lz_compose_types_v2::{get_accounts_for_clear_compose, Instruction, LzComposeTypesV2Result},
LzComposeParams,
};
#[derive(Accounts)]
#[instruction(params: LzComposeParams)]
pub struct LzComposeTypesV2<'info> {
#[account(seeds = [STORE_SEED], bump = store.bump)]
pub store: Account<'info, Store>,
}
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![
AccountMetaRef { pubkey: ctx.accounts.store.key().into(), is_writable: true },
];
// Endpoint helper accounts for compose
let accounts_for_composing = get_accounts_for_clear_compose(
ENDPOINT_ID,
¶ms.from,
&ctx.accounts.store.key(), // receiver (self or target PDA)
¶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)?,
}],
})
}
}
Finally, register the new instruction handlers in your lib.rs:
#[program]
pub mod my_oapp {
use super::*;
// ...the existing instructions
pub fn lz_compose_types_v2(
ctx: Context<LzComposeTypesV2>,
params: LzComposeParams,
) -> Result<LzComposeTypesV2Result> {
LzComposeTypesV2::apply(&ctx, ¶ms)
}
pub fn lz_compose_types_info(
ctx: Context<LzComposeTypesInfo>,
params: LzComposeParams,
) -> Result<(u8, LzComposeTypesV2Accounts)> {
LzComposeTypesInfo::apply(&ctx, ¶ms)
}
}
Gotchas & common errors
| Error | Usual cause |
|---|---|
AccountNotSigner on slot N | You omitted a signer placeholder or swapped two accounts. |
InvalidProgramId (Endpoint) | Wrong Endpoint ID; check you passed the same constant everywhere. |
| Transaction > 1232 bytes | Too many accounts in your Vec → trim until ALTs support arrives (consider migrating to LzReceiveTypesV2 ). |
Executor halts at lz_receive | Your lz_receive_types returned fewer accounts than lz_receive expects. |