LayerZero V2 Solana OApp `lz_receive_types` v1 (Legacy)
This page documents the original lz_receive_types v1 flow for Solana OApps.
- If you are building a new Solana OApp, you should instead implement
lz_receive_types_v2. - If you already shipped production code on v1, use this page as a maintenance reference and migration aid.
High-level message flow (v1)
The original Solana OApp flow discovers accounts for lz_receive via a single lz_receive_types instruction that returns a flat Vec<LzAccount>:
┌────────┐ (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
Initialize the OApp PDA (v1)
The legacy example initializes the Store PDA plus the lz_receive_types and lz_compose_types PDAs in a single init_store instruction:
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(())
}
}
lz_receive_types — tell the Executor which accounts are needed by lz_receive (v1)
When the Executor calls lz_receive on any OApp program, it must provide a list of all accounts that are read or written to. In v1, the Solana OApp standard does this via the lz_receive_types instruction, which returns a flat Vec<LzAccount>:
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 (v1)
- 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 (v1 example)
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 returns 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.
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 withinlz_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.
lz_receive_types v1 vs lz_receive_types_v2
The table below summarizes the differences between the legacy v1 and the recommended v2 flows:
| Aspect | lz_receive_types v1 | lz_receive_types_v2 |
|---|---|---|
| Account discovery model | Single CPI returning a flat Vec<LzAccount> used directly as lz_receive accounts. | Two-step flow: lz_receive_types_info returns versioned data; lz_receive_types_v2 returns an execution plan with compact account references. |
| Return type | Raw Vec<LzAccount> limited by Solana’s 1024-byte CPI return data. | LzReceiveTypesV2Result (context version, ALTs, and a list of high-level instructions such as Instruction::LzReceive). |
| Address Lookup Tables (ALTs) | Not supported; all accounts must fit in the base transaction account limit. | Explicit ALTs support; can pass multiple ALTs and use compact_accounts_with_alts for significantly more accounts. |
| Multi-instruction support | Single-instruction model; transaction effectively executes only lz_receive. | Supports multi-instruction execution plans within a single transaction, including pre/post-execute and complex workflows. |
| ABA messaging pattern | Not supported due to lack of lz_send account discovery and CPI depth limits. | Designed to support more complex patterns (including ABA-style flows) via flexible account discovery and execution planning. |
| EOA signer handling | Only the Executor’s single signer is available; no built-in mechanism to introduce extra EOAs as signers. | Explicit support for multiple EOAs in the execution context, enabling dynamic account initialization and richer multi-party flows. |
| Fee limit visibility | OApp has no visibility into the Executor’s internal SOL spending limit. | Executor uses pre_execute/post_execute with an execution context version, enforcing fee limits in a way that is visible and reasoned about. |
| Recommended usage | Legacy only — keep for existing deployments and maintenance. | Default going forward — use for all new Solana OApps and when migrating existing OApps away from v1. |
Gotchas & common errors (primarily v1)
These errors are most commonly encountered when using lz_receive_types v1:
| 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 → Ensure you use ALTs to reduce the transaction size |
Executor halts at lz_receive | Your lz_receive_types returned fewer accounts than lz_receive expects. |