Skip to main content
Version: Endpoint V2 Docs

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.

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:

EVMSolana
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 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.
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.

ComponentWhat it isSeed / DerivationWhy it matters
OApp Store PDAZero-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 PDAFlat list of static accounts you’ll echo back[b"LzReceiveTypes", store]Queried off-chain; never touched on-chain
lz_compose_types PDASame 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.

Running the example Solana OApp

You can install the example Solana OApp using the create-lzoapp CLI:

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

The CLI will set up a string-passing OApp project involving EVM and Solana. Follow the included README for the deployment instructions.

The following sections will highlight the several code excerpts of the Solana OApp that are essential to it functioning.

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 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_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.
  • The string field 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(), &params.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,
&params.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(&params.message) == msg_codec::COMPOSED_TYPE {
accs.extend(get_accounts_for_send_compose(
ENDPOINT_ID,
&store, // payer = this PDA
&store, // receiver (self-compose)
&params.guid,
0, // fee = 0, Executor pre-funds
&params.message,
));
}
Ok(accs)
}

Rules of thumb

  1. Exact order mattersLzAccount[0] in the Vec becomes account #0 in the eventual tx.
  2. Signer placeholders: If some downstream CPI (e.g. ATA init) needs a signer, pass pubkey = Pubkey::default(), is_signer = true.
  3. 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.


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(), &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
let new_string = msg_codec::decode(&params.message);
ctx.accounts.store.string = new_string;

Ok(())
}

Rules of thumb

  • Always clear() first; any panics after that leave the message permanently consumed.
  • 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 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.

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 your Vec → trim until ALTs support arrives (see Address Lookup Tables ).
Executor halts at lz_receiveYour lz_receive_types returned fewer accounts than lz_receive expects.

Roadmap: Address Lookup Tables (Q3 2025)

LayerZero’s Solana SDK will expose lz_receive_types_alt / lz_receive_alt variants that accept:

  • lookup_table_pubkey – pre-populated ALT containing rarely-changed accounts
  • Regular inline Vec for the “hot” accounts (store, peer, payer…)

Early benchmarks show > 80 accounts fit comfortably once ALTs remove them from the tx message; return-data stays under 500 bytes. This page will be updated once that has been released.