Skip to main content
Version: Endpoint V2

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:

ThemeEVMSolana
Endpoint integration patternOApp 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.
Execution model & account discoveryDynamic 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_v2 instruction.
OApp identity & addressingThe OApp contract's address is used as the OApp addressThe 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.
Configuration storage & ownershipPathway 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

Due to the need for the inclusion of accounts, the flow of an inbound message on Solana is different from that on an EVM chain:

       ┌────────┐           (1) msg packet to receiver PDA
Source │ Src │ ──────────────────────────────────────────► Solana
Chain │ OApp │ │
└────────┘ ▼
┌─────────────────────────┐
│ Executor program │
└─────────────────────────┘

(2) Account + Instructions Discovery ──────┘


(3) CPI: `lz_receive` + other instructions ──────┘



┌────────┐
│ Dst │
│ OApp │
└────────┘
(4) Endpoint and OApp state updated

Accounts and Instructions Discovery

The discovery of accounts and instructions is handled by lz_receive_types_v2.

What lz_receive_types_v2 introduces

lz_receive_types_v2 has 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)


  1. 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, the oapp_account is the store account.
      • lz_receive_types_accounts - PDA derived with seeds = [LZ_RECEIVE_TYPES_SEED, &oapp_account.key().to_bytes()].
    • returns (version, versioned_data)
      • version: u8 — A protocol-defined version identifier for the LzReceiveType logic and return type, starting from 2.
      • versioned_data: Any — A value of type Any, 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 invoke lz_receive_types_v2 (LzReceiveTypesV2Accounts).
  2. lz_receive_types_v2 - this instruction is called with LzReceiveTypesV2Accounts as the supplied accounts and returns:
    • the context_version
    • ALTs used (if any)
    • The full list of instructions for lz_receive
  3. build and submit transaction - now the Executor can prepare the full transaction and submit it based on what was returned by lz_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.
info

All the instructions above are called by the Executor. As the OApp developer, you only need to ensure that you implement the required instructions and PDAs in your OApp program.

Required PDAs

The following are the PDAs that are required for a Solana OApp.

ComponentWhat it isSeed / DerivationWhy it matters
OApp StoreZero-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 Config(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_accountsPDA that stores the set of accounts required for lz_receive_types_v2.[b"LzReceiveTypes", store]Used by the Executor to look up and provide the correct account list for execution.

Note that except for the Store PDA, the PDA seeds are not customizable.

Peer Config PDAs are initialized by the wiring step.

Required Instructions

InstructionPurposeWhen it is called
lz_receive_types_infoReturns the version and the accounts set used to construct the lz_receive_types_v2 call.Called off-chain by the Executor to discover which accounts to use when calling lz_receive_types_v2.
lz_receive_types_v2Returns the full execution plan (LzReceiveTypesV2Result) including ALTs and the instructions needed to run lz_receive.Called off-chain by the Executor after lz_receive_types_info to obtain the accounts/ALTs and instructions used to build the final transaction.
lz_receiveExecutes the OApp’s business logic and calls Endpoint::clear (and optionally send_compose) for an inbound message.Invoked by the Executor inside the transaction built from the lz_receive_types_v2 execution plan.
initInitializes the OApp Store and related PDAs, and registers the OApp with the Endpoint.Called from your deployment script or client before any cross-chain messages are processed.

Initialize the OApp PDA

This init instruction initializes 2 required PDAs: OApp Store and lz_receive_types_accounts.

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(())
}
}

Key points

  • You must call oapp::endpoint_cpi::register_oapp to 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 Peer PDAs enforce.
  • You can extend your OApp's store PDA with other fields required by your use case.

Implement 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)]
pub payer: Signer<'info>,
#[account(mut, seeds = [STORE_SEED], bump = store.bump)]
pub store: Account<'info, Store>,
#[account(
seeds = [PEER_SEED, store.key().to_bytes(), &params.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
// ...

Ok(())
}

Rules of thumb

  • Call clear() before touching any user state—this burns the nonce and prevents re-entry.
  • Use ctx.remaining_accounts instead of hard-wiring anything—keeps lz_receive_types_v2 and lz_receive perfectly in sync.
  • Don’t forget is_signer: true zero-pubkey placeholders for ATA init or rent payer.

Security Reminders

  • Validate the Peer account first (constraint = params.sender == peer.address).
  • Store the Endpoint ID inside state (store.endpoint_program) and assert it every CPI.

Implement 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.
}

store here is the OApp account referred to as oapp_account in the flow description.

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
let peer_seeds = [PEER_SEED, &params.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, &params)
}

pub fn lz_receive_types_info(
ctx: Context<LzReceiveTypesInfo>,
params: LzReceiveParams,
) -> Result<(u8, LzReceiveTypesV2Accounts)> {
LzReceiveTypesInfo::apply(&ctx, &params)
}
}

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 length and the actual string
  • If sending across VMs, ensure the codec on the other VM matches.

Gotchas & common errors

ErrorUsual cause
AccountNotSigner on slot NYou omitted a signer placeholder or swapped two accounts.
InvalidProgramId (Endpoint)Wrong Endpoint ID; check you passed the same constant everywhere.
Transaction > 1232 bytesToo many accounts in the transaction → ensure you use ALTs.
Executor halts at lz_receiveYour lz_receive_types_v2 returned fewer accounts than lz_receive expects.