LayerZero V2 Solana Technical Overview
LayerZero V2 on Solana mirrors the design of the EVM version in that it coordinates cross-chain messaging through multiple protocol smart contracts. However, instead of EVM contracts and events, Solana programs use CPIs (cross–program invocations), PDAs (program–derived addresses), and a series of instructions that are tightly validated by Anchor:
Send Workflow: How a cross-chain message packet is created, fees calculated via the Message Library, and sent from the source chain.
DVN Verification Workflow: How an application's configured decentralized verifier networks (DVNs) initialize and later verify the message payload.
Executor Workflow: How the Executor program finally executes the message (invoking the receiving OApp via an
lzReceive
call).
Send Overview
When a user sends a cross-chain message, the following high–level steps occur:
Endpoint Program
Send Instruction on the LayerZero Endpoint:
TheSend
instruction is called on the Endpoint program via a CPI call from another program :Increments the outbound nonce.
Constructs a unique packet (including a GUID computed via a hash of parameters).
Invokes the send library (e.g. ULN302) via CPI to calculate fee allocations and emit the corresponding events.
impl Send<'_> {
/// Applies the send function, which sends a LayerZero message packet.
///
/// # Parameters
/// - `ctx`: The execution context containing all required accounts.
/// - `params`: The parameters for sending, which include destination, receiver, message payload, fee details, and options.
///
/// # Returns
/// - `MessagingReceipt`: Contains the unique GUID, the nonce, and the fee breakdown for the sent message.
pub fn apply<'c: 'info, 'info>(
ctx: &mut Context<'_, '_, 'c, 'info, Send<'info>>,
params: &SendParams,
) -> Result<MessagingReceipt> {
// 1. Increment the outbound nonce.
// Each message sent increases the nonce to guarantee a gapless and unique message sequence.
ctx.accounts.nonce.outbound_nonce += 1;
// 2. Build and encode the packet:
// - Retrieve the sender's address.
// - Generate a globally unique identifier (GUID) for the message using the new nonce,
// the source Endpoint's ID, sender address, destination endpoint, and receiver.
let sender = ctx.accounts.sender.key();
let guid = get_guid(
ctx.accounts.nonce.outbound_nonce,
ctx.accounts.endpoint.eid,
sender,
params.dst_eid,
params.receiver,
);
// Create the packet structure with all the message details.
let packet = Packet {
nonce: ctx.accounts.nonce.outbound_nonce,
src_eid: ctx.accounts.endpoint.eid,
sender,
dst_eid: params.dst_eid,
receiver: params.receiver,
guid,
message: params.message.clone(),
};
// 3. Validate the configured send library:
// This ensures that the correct send library is in use for this application and destination.
let send_library = assert_send_library(
&ctx.accounts.send_library_info,
&ctx.accounts.send_library_program.key,
&ctx.accounts.send_library_config,
&ctx.accounts.default_send_library_config,
)?;
// 4. Set up the CPI call:
// Prepare the seeds needed to sign the CPI call to the send library.
let seeds: &[&[&[u8]]] = &[&[MESSAGE_LIB_SEED, send_library.as_ref(), &[ctx.accounts.send_library_info.bump]]];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.send_library_program.to_account_info(),
messagelib_interface::cpi::accounts::Interface {
endpoint: ctx.accounts.send_library_info.to_account_info(),
},
seeds,
)
.with_remaining_accounts(ctx.remaining_accounts.to_vec());
// 5. Call the send library via CPI:
// The send library implements two interfaces: one for sending with native tokens,
// and one for sending with LZ token fees. Here we decide which to call based on the fee provided.
let (fee, encoded_packet) = if params.lz_token_fee == 0 {
// When paying with native tokens:
let send_params = messagelib_interface::SendParams {
packet,
options: params.options.clone(),
native_fee: params.native_fee,
};
messagelib_interface::cpi::send(cpi_ctx, send_params)?.get()
} else {
// When paying with LZ tokens:
let lz_token_mint = ctx.accounts.endpoint.lz_token_mint
.ok_or(LayerZeroError::LzTokenUnavailable)?;
let send_params = messagelib_interface::SendWithLzTokenParams {
packet,
options: params.options.clone(),
native_fee: params.native_fee,
lz_token_fee: params.lz_token_fee,
lz_token_mint,
};
messagelib_interface::cpi::send_with_lz_token(cpi_ctx, send_params)?.get()
};
// 6. Emit an event to signal that a packet has been sent.
// This event notifies offchain infrastructure (like DVNs and executors) about the sent message.
emit_cpi!(PacketSentEvent {
encoded_packet,
options: params.options.clone(),
send_library,
});
// 7. Return a MessagingReceipt containing the GUID, nonce, and fee details.
Ok(MessagingReceipt { guid, nonce: ctx.accounts.nonce.outbound_nonce, fee })
}
}
SendUln302 Program
Fee Quotation and Payment via CPI:
The send library (ULN302) uses instructions likeQuoteExecutor
andQuoteDvn
via a series of CPI calls to programs such as the Executor and DVN.impl Quote<'_> {
/// Applies the quote function, which calculates the messaging fee required for sending a packet.
///
/// # Parameters
/// - `ctx`: The execution context containing all required accounts.
/// - `params`: The parameters for quoting, including packet details and options.
///
/// # Returns
/// - `MessagingFee`: The fee breakdown (native fee and LZ token fee).
pub fn apply(ctx: &Context<Quote>, params: &QuoteParams) -> Result<MessagingFee> {
// Retrieve the configuration for the ULN (send configuration) and the executor configuration.
// This function merges the custom configuration from the OApp with the default configuration.
let (uln_config, executor_config) =
get_send_config(&ctx.accounts.send_config, &ctx.accounts.default_send_config)?;
// Decode the options passed in the quote parameters.
// The options might include specific settings for the executor and DVN fee calculations.
let (executor_options, dvn_options) = decode_options(¶ms.options)?;
// --------------------------
// CPI call to the Executor for fee quotation.
// This call queries the executor configuration to estimate the fee based on:
// - The ULN's key (which represents the OApp's messaging context)
// - The destination endpoint ID
// - The sender and the length of the message payload
// - Specific executor options (e.g., gas or compute units)
// - A slice of the remaining accounts expected to be used by the executor CPI call
// --------------------------
let executor_fee = quote_executor(
&ctx.accounts.uln.key(),
&executor_config,
params.packet.dst_eid,
¶ms.packet.sender,
params.packet.message.len() as u64,
executor_options,
&ctx.remaining_accounts[0..4],
)?;
// --------------------------
// CPI call to the DVN(s) for fee quotation.
// This call queries the configured DVNs to get their fee quotes based on:
// - The ULN's key (providing the messaging context)
// - The ULN configuration which includes DVN settings
// - The destination endpoint ID and sender details
// - The encoded packet header and the hashed payload (GUID + message)
// - Specific DVN options (if any)
// - A slice of the remaining accounts expected to be used for DVN CPI calls
// --------------------------
let dvn_fees = quote_dvns(
&ctx.accounts.uln.key(),
&uln_config,
params.packet.dst_eid,
¶ms.packet.sender,
encode_packet_header(¶ms.packet),
hash_payload(¶ms.packet.guid, ¶ms.packet.message),
dvn_options,
&ctx.remaining_accounts[4..],
)?;
// Sum up the fees from both the executor and DVNs.
// Here, `worker_fee` is the total fee required to cover the processing by both workers.
let worker_fee = executor_fee.fee + dvn_fees.iter().map(|f| f.fee).sum::<u64>();
// Calculate the final fee breakdown based on treasury settings.
// If the ULN treasury is configured, determine the treasury fee and adjust the native fee or LZ token fee
// depending on whether fees are being paid in LZ token.
let (native_fee, lz_token_fee) = if let Some(treasury) = ctx.accounts.uln.treasury.as_ref() {
let treasury_fee = quote_treasury(treasury, worker_fee, params.pay_in_lz_token)?;
if params.pay_in_lz_token {
// When paying with LZ token, the native fee remains as the worker fee,
// and the treasury fee is taken from the LZ token fee.
(worker_fee, treasury_fee)
} else {
// Otherwise, add the treasury fee to the worker fee and set LZ token fee to 0.
(worker_fee + treasury_fee, 0)
}
} else {
// If no treasury is configured, the fee is simply the worker fee.
(worker_fee, 0)
};
// Return the final messaging fee.
Ok(MessagingFee { native_fee, lz_token_fee })
}
}Endpoint Packet Emission:
Finally, after fee calculations and transfers, the Endpoint program emits an event (e.g.PacketSentEvent
) and the packet is recorded on-chain.// packages/layerzero-v2/solana/programs/programs/endpoint/src/instructions/oapp/send.rs
emit_cpi!(PacketSentEvent {
encoded_packet,
options: params.options.clone(),
send_library,
});
Ok(MessagingReceipt { guid, nonce: ctx.accounts.nonce.outbound_nonce, fee })
Verification Workflow
After the send operation, the DVNs must verify the message on the destination chain before message execution.
On Solana, every account must be explicitly allocated with sufficient space. For DVN verification, this means a dedicated payload hash account is first created and initialized. This ensures that when a DVN writes its witness, the storage exists and is correctly sized.
DVN Verification
Each DVN individually performs the following steps:
Initialization with
initVerify
:
The DVN callsinitVerify
on the ULN program to create and initialize a dedicatedPayloadHash
account. This account reserves the necessary space and is initialized with a default empty hash (EMPTY_PAYLOAD_HASH
) along with a PDA bump.// packages/layerzero-v2/solana/programs/programs/uln/src/instructions/dvn/init_verify.rs
// This function initializes the payload_hash account used for DVN verification.
impl InitVerify<'_> {
pub fn apply(ctx: &mut Context<InitVerify>, _params: &InitVerifyParams) -> Result<()> {
// Set the initial hash to indicate that no payload has been verified yet.
ctx.accounts.payload_hash.hash = EMPTY_PAYLOAD_HASH;
// Store the bump value for PDA derivation.
ctx.accounts.payload_hash.bump = ctx.bumps.payload_hash;
Ok(())
}
}Invocation with
invoke
:
After initialization, the DVN triggers its own verification logic via aninvoke
instruction. This CPI call executes internal checks (such as signature verification and configuration validation) and, in the process, calls into the ULN’s verification logic by triggering a CPI call to theverify
instruction.// packages/layerzero-v2/solana/programs/programs/dvn/src/instructions/admin/invoke.rs
impl Invoke<'_> {
/// Applies the DVN verification logic by processing the execution digest.
/// Ultimately, this invoke call triggers a CPI to the ULN's `verify` instruction.
pub fn apply(ctx: &mut Context<Invoke>, params: &InvokeParams) -> Result<()> {
// 1. Verify that the DVN configuration version (vid) matches the digest's version.
require!(ctx.accounts.config.vid == params.digest.vid, DvnError::InvalidVid);
// 2. Check that the transaction has not expired.
require!(params.digest.expiration > Clock::get()?.unix_timestamp, DvnError::Expired);
// 3. Compute the hash of the digest data; used for signature verification.
let hash = keccak::hash(¶ms.digest.data()?).to_bytes();
// 4. Verify that the provided signatures are valid for the computed hash.
ctx.accounts.config.multisig.verify_signatures(¶ms.signatures, &hash)?;
// 5. Update the execute_hash account with the expiration and bump.
ctx.accounts.execute_hash.expiration = params.digest.expiration;
ctx.accounts.execute_hash.bump = ctx.bumps.execute_hash;
// 6. Process the digest based on the target program ID.
if params.digest.program_id == ID {
// If the digest targets this DVN program:
let mut data = params.digest.data.as_slice();
let config = MultisigConfig::deserialize(&mut data)?;
let is_set_admin = matches!(config, MultisigConfig::Admins(_));
if !is_set_admin {
require!(
ctx.accounts.config.admins.contains(ctx.accounts.signer.key),
DvnError::NotAdmin
);
}
config.apply(&mut ctx.accounts.config)?;
emit_cpi!(MultisigConfigSetEvent { config });
} else {
// If the digest targets a different program:
require!(
ctx.accounts.config.admins.contains(ctx.accounts.signer.key),
DvnError::NotAdmin
);
let mut accounts = Vec::with_capacity(params.digest.accounts.len());
let config_acc = ctx.accounts.config.key();
for acc in params.digest.accounts.iter() {
let mut meta = AccountMeta::from(acc);
if meta.pubkey == config_acc && acc.is_signer {
meta.is_writable = false;
}
accounts.push(meta);
}
let ix = Instruction {
program_id: params.digest.program_id,
accounts,
data: params.digest.data.clone(),
};
invoke_signed(
&ix,
ctx.remaining_accounts,
&[&[DVN_CONFIG_SEED, &[ctx.accounts.config.bump]]],
)?;
}
Ok(())
}
}Final Verification via
verify
:
Once the DVN’s internal verification logic completes and the conditions are met, the ULN program finalizes the DVN verification by calling its ownverify
function. This function updates the DVN-specific payload hash and emits aPayloadVerifiedEvent
to signal that the message has been verified by that DVN.// packages/layerzero-v2/solana/programs/programs/uln/src/instructions/dvn/verify.rs
// This function finalizes the DVN verification process on the ULN side.
impl Verify<'_> {
pub fn apply(ctx: &mut Context<Verify>, params: &VerifyParams) -> Result<()> {
ctx.accounts.confirmations.value = Some(params.confirmations);
emit_cpi!(PayloadVerifiedEvent {
dvn: ctx.accounts.dvn.key(),
header: params.packet_header,
confirmations: params.confirmations,
proof_hash: params.payload_hash,
});
Ok(())
}
}
Summary of DVN Verification:
ReceiveUln.initVerify()
: Initializes a dedicated payload hash account with an empty hash.DVN.invoke()
: Executes the DVN’s internal verification logic and triggers the ULN’sverify
instruction via a nested CPI.ReceiveUln.verify()
: The ULN finalizes the verification by updating the payload hash and emitting aPayloadVerifiedEvent
.
Commit Verification
After all required verifications have been submitted (meeting the X of Y of N configuration), the payload hash can then be committed. The commit verification process ensures that the verified message is recorded in the Endpoint’s messaging channel. This process comprises two primary steps:
Initialization via
initVerify
on the Endpoint:
Before committing the verification, the system callsinitVerify
on the Endpoint. This creates and initializes a dedicated payload hash account, reserving space for the verification data. The account is set up with an initial empty payload hash (EMPTY_PAYLOAD_HASH
) and a bump value for PDA derivation.// packages/layerzero-v2/solana/programs/programs/endpoint/src/instructions/init_verify.rs
impl InitVerify<'_> {
pub fn apply(ctx: &mut Context<InitVerify>, _params: &InitVerifyParams) -> Result<()> {
// Initialize with an empty payload hash.
ctx.accounts.payload_hash.hash = EMPTY_PAYLOAD_HASH;
// Save the bump value for future PDA derivation.
ctx.accounts.payload_hash.bump = ctx.bumps.payload_hash;
Ok(())
}
}Committing Verification via
commitVerification
on ReceiveUln302:
Once the payload hash account is initialized and DVN confirmations have been collected, theReceiveUln302.commitVerification()
function is called to finalize the verification by:- Validating the Packet Header:
It checks that the header version is correct and that the destination endpoint ID (EID) matches the ULN302’s configured EID. - Verifying DVN Confirmations:
It calculates the number of DVN confirmation accounts (both required and optional) and uses helper functions (e.g.,check_verifiable
andverified
) to ensure that every DVN has provided sufficient confirmation. - CPI to the Endpoint’s
verify
Instruction:
If all checks pass, a CPI call is made to the Endpoint’sverify
function. This call updates the payload hash stored in the dedicated account and emits aPacketVerifiedEvent
, thereby recording the verified message on the Endpoint’s messaging channel.
// packages/layerzero-v2/solana/programs/programs/uln/src/instructions/dvn/commit_verification.rs
impl CommitVerification<'_> {
pub fn apply(
ctx: &mut Context<CommitVerification>,
params: &CommitVerificationParams,
) -> Result<()> {
// Retrieve the effective receive configuration (combining custom and default settings)
let config = get_receive_config(
&ctx.accounts.receive_config,
&ctx.accounts.default_receive_config
)?;
// Validate the packet header:
// 1. Ensure the header version matches the expected version.
require!(
packet_v1_codec::version(¶ms.packet_header) == PACKET_VERSION,
UlnError::InvalidPacketVersion
);
// 2. Ensure the destination EID matches the ULN302's configured EID.
require!(
packet_v1_codec::dst_eid(¶ms.packet_header) == ctx.accounts.uln.eid,
UlnError::InvalidEid
);
// Determine the number of DVN accounts (required and optional)
let dvns_size = config.required_dvns.len() + config.optional_dvns.len();
// Verify that all DVN confirmation accounts provide a valid confirmation.
let confirmation_accounts = &ctx.remaining_accounts[0..dvns_size];
require!(
check_verifiable(
&config,
confirmation_accounts,
&keccak256(¶ms.packet_header).to_bytes(),
¶ms.payload_hash
)?,
UlnError::Verifying
);
// Commit the verification by calling the Endpoint's verify instruction via CPI.
endpoint_verify::verify(
ctx.accounts.uln.endpoint_program,
ctx.accounts.uln.key(),
¶ms.packet_header,
params.payload_hash,
&[ULN_SEED, &[ctx.accounts.uln.bump]],
&ctx.remaining_accounts[dvns_size..],
)
}
}- Validating the Packet Header:
Insert Hash into the Endpoint's Message Channel via
verify
:
The Endpoint’sverify
method is the final step in the commit verification process. Once invoked via CPI, it performs the following actions:- Nonce Management:
It checks if the packet’s nonce is greater than the current inbound nonce and updates the pending inbound nonce if necessary. - Updating the Payload Hash:
The verified payload hash is written into the payload hash account. - Event Emission:
APacketVerifiedEvent
is emitted, signaling that the packet has been verified and recorded on-chain.
// packages/layerzero-v2/solana/programs/programs/endpoint/src/instructions/verify.rs
use crate::*;
use cpi_helper::CpiContext;
use solana_program::clock::Slot;
/// MESSAGING STEP 2
/// requires init_verify()
#[event_cpi]
#[derive(CpiContext, Accounts)]
#[instruction(params: VerifyParams)]
pub struct Verify<'info> {
/// The PDA of the receive library.
#[account(
constraint = is_valid_receive_library(
receive_library.key(),
&receive_library_config,
&default_receive_library_config,
Clock::get()?.slot
) @LayerZeroError::InvalidReceiveLibrary
)]
pub receive_library: Signer<'info>,
#[account(
seeds = [RECEIVE_LIBRARY_CONFIG_SEED, ¶ms.receiver.to_bytes(), ¶ms.src_eid.to_be_bytes()],
bump = receive_library_config.bump
)]
pub receive_library_config: Account<'info, ReceiveLibraryConfig>,
#[account(
seeds = [RECEIVE_LIBRARY_CONFIG_SEED, ¶ms.src_eid.to_be_bytes()],
bump = default_receive_library_config.bump
)]
pub default_receive_library_config: Account<'info, ReceiveLibraryConfig>,
#[account(
mut,
seeds = [
NONCE_SEED,
¶ms.receiver.to_bytes(),
¶ms.src_eid.to_be_bytes(),
¶ms.sender[..]
],
bump = nonce.bump
)]
pub nonce: Account<'info, Nonce>,
#[account(
mut,
seeds = [
PENDING_NONCE_SEED,
¶ms.receiver.to_bytes(),
¶ms.src_eid.to_be_bytes(),
¶ms.sender[..]
],
bump = pending_inbound_nonce.bump
)]
pub pending_inbound_nonce: Account<'info, PendingInboundNonce>,
#[account(
mut,
seeds = [
PAYLOAD_HASH_SEED,
¶ms.receiver.to_bytes(),
¶ms.src_eid.to_be_bytes(),
¶ms.sender[..],
¶ms.nonce.to_be_bytes()
],
bump = payload_hash.bump,
constraint = params.payload_hash != EMPTY_PAYLOAD_HASH @LayerZeroError::InvalidPayloadHash
)]
pub payload_hash: Account<'info, PayloadHash>,
}
impl Verify<'_> {
pub fn apply(ctx: &mut Context<Verify>, params: &VerifyParams) -> Result<()> {
// No need for initializable() or verifiable() checks, as init_verify() already enforces the nonce requirement.
// Update the pending inbound nonce if the message nonce is greater.
if params.nonce > ctx.accounts.nonce.inbound_nonce {
ctx.accounts
.pending_inbound_nonce
.insert_pending_inbound_nonce(params.nonce, &mut ctx.accounts.nonce)?;
}
// Write the verified payload hash into the payload hash account.
ctx.accounts.payload_hash.hash = params.payload_hash;
// Emit an event to signal that the packet has been verified.
emit_cpi!(PacketVerifiedEvent {
src_eid: params.src_eid,
sender: params.sender,
receiver: params.receiver,
nonce: params.nonce,
payload_hash: params.payload_hash,
});
Ok(())
}
}
#[derive(Clone, AnchorSerialize, AnchorDeserialize)]
pub struct VerifyParams {
pub src_eid: u32,
pub sender: [u8; 32],
pub receiver: Pubkey,
pub nonce: u64,
pub payload_hash: [u8; 32],
}- Nonce Management:
Summary of Commit Verification:
Endpoint.initVerify()
: Creates and initializes a dedicated payload hash account with an empty hash.ReceiveUln302.commitVerification()
: Validates the packet header and DVN confirmations, then commits the verification by calling the Endpoint'sverify
via CPI.Endpoint.verify()
: Inserts the verified payload hash into the messaging channel, updates nonce management, and emits aPacketVerifiedEvent
.
Together, these steps ensure that only messages with sufficient DVN confirmations are recorded on-chain in the Endpoint's messaging channel, thereby maintaining the integrity and security of the cross-chain message.
Receive Workflow
The Solana receive flow is divided into three primary stages:
Execute:
The Executor program initiates the message execution process by calling itsexecute
instruction. In this step, the Executor:Gathers all required accounts.
Invokes downstream instructions via CPI to eventually call
lzReceive
.Checks that its lamport balance does not drop unexpectedly.
If the CPI call fails, an alert is triggered via
lzReceiveAlert
.
// packages/layerzero-v2/solana/programs/programs/executor/src/instructions/execute.rs
#[event_cpi]
#[derive(Accounts)]
pub struct Execute<'info> {
#[account(mut)]
pub executor: Signer<'info>,
#[account(
seeds = [EXECUTOR_CONFIG_SEED],
bump = config.bump,
constraint = config.executors.contains(executor.key) @ExecutorError::NotExecutor
)]
pub config: Account<'info, ExecutorConfig>,
pub endpoint_program: Program<'info, Endpoint>,
/// The authority for the endpoint program to emit events
pub endpoint_event_authority: UncheckedAccount<'info>,
}
impl Execute<'_> {
pub fn apply(ctx: &mut Context<Execute>, params: &ExecuteParams) -> Result<()> {
let balance_before = ctx.accounts.executor.lamports();
let program_id = ctx.remaining_accounts[0].key();
let accounts = ctx
.remaining_accounts
.iter()
.skip(1)
.map(|acc| acc.to_account_metas(None)[0].clone())
.collect::<Vec<_>>();
let data = get_lz_receive_ix_data(¶ms.lz_receive)?;
let result = invoke(&Instruction { program_id, accounts, data }, ctx.remaining_accounts);
if let Err(e) = result {
// If execution fails, trigger an alert.
let params = LzReceiveAlertParams { /* omitted for brevity */ };
let cpi_ctx = LzReceiveAlert::construct_context(
ctx.accounts.endpoint_program.key(),
&[
ctx.accounts.config.to_account_info(), // executor config as signer
ctx.accounts.endpoint_event_authority.to_account_info(),
ctx.accounts.endpoint_program.to_account_info(),
],
)?;
endpoint::cpi::lz_receive_alert(
cpi_ctx.with_signer(&[&[EXECUTOR_CONFIG_SEED, &[ctx.accounts.config.bump]]]),
params,
)?;
} else {
// Ensure the executor did not lose more lamports than expected.
let balance_after = ctx.accounts.executor.lamports();
require!(
balance_before <= balance_after + params.value,
ExecutorError::InsufficientBalance
);
}
require!(
ctx.accounts.executor.owner.key() == system_program::ID,
ExecutorError::InvalidOwner
);
require!(ctx.accounts.executor.data_is_empty(), ExecutorError::InvalidSize);
Ok(())
}
}LzReceiveTypes – Account Assembly:
ThelzReceiveTypes
instruction gathers all the accounts required by the final message execution. This step constructs the list of accounts—including PDAs for the peer, configuration accounts, token escrow (if needed), token destination, mint, and various system accounts—based on the parameters of the received message.// packages/solana/programs/counter/src/instructions/lz_receive_types.rs
use crate::*;
use oapp::endpoint_cpi::{get_accounts_for_clear, get_accounts_for_send_compose, LzAccount};
use oapp::{endpoint::ID as ENDPOINT_ID, LzReceiveParams};
/// LzReceiveTypes provides the list of accounts required in the subsequent LzReceive instruction.
#[derive(Accounts)]
pub struct LzReceiveTypes<'info> {
#[account(seeds = [COUNT_SEED, &count.id.to_be_bytes()], bump = count.bump)]
pub count: Account<'info, Count>,
}
impl LzReceiveTypes<'_> {
pub fn apply(
ctx: &Context<LzReceiveTypes>,
params: &LzReceiveParams,
) -> Result<Vec<LzAccount>> {
// Determine the fixed count account.
let count = ctx.accounts.count.key();
// Derive the remote PDA using the source endpoint id.
let seeds = [REMOTE_SEED, &count.to_bytes(), ¶ms.src_eid.to_be_bytes()];
let (remote, _) = Pubkey::find_program_address(&seeds, ctx.program_id);
// Start with the count and remote accounts.
let mut accounts = vec![
LzAccount { pubkey: count, is_signer: false, is_writable: true },
LzAccount { pubkey: remote, is_signer: false, is_writable: false },
];
// Append accounts required by the clear instruction (from the Endpoint).
let accounts_for_clear = get_accounts_for_clear(
ENDPOINT_ID,
&count,
params.src_eid,
¶ms.sender,
params.nonce,
);
accounts.extend(accounts_for_clear);
// If the message type is composed, append accounts for the compose instruction.
let is_composed = msg_codec::msg_type(¶ms.message) == msg_codec::COMPOSED_TYPE;
if is_composed {
let accounts_for_composing = get_accounts_for_send_compose(
ENDPOINT_ID,
&count,
&count, // self, for example
¶ms.guid,
0,
¶ms.message,
);
accounts.extend(accounts_for_composing);
}
Ok(accounts)
}
}LzReceive – Final Message Execution:
Finally, thelzReceive
instruction executes the received message. This is where the actual processing occurs. In this step, the program must implement safety checks that clear the payload to prevent reentrancy and double execution. Specifically, it:- Clears the Payload:
Updates nonces, verifies that the payload hash matches the verified data, and deletes the message from storage. - Performs Token Operations (if applicable):
Depending on the message type, it may mint tokens or perform transfers. - Emits an Event:
Signals that the message has been successfully received and processed.
// packages/solana/programs/counter/src/instructions/lz_receive.rs
use crate::*;
use anchor_lang::prelude::*;
use oapp::{
endpoint::{
cpi::accounts::Clear,
instructions::{ClearParams, SendComposeParams},
ConstructCPIContext, ID as ENDPOINT_ID,
},
LzReceiveParams,
};
#[derive(Accounts)]
#[instruction(params: LzReceiveParams)]
pub struct LzReceive<'info> {
#[account(mut, seeds = [COUNT_SEED, &count.id.to_be_bytes()], bump = count.bump)]
pub count: Account<'info, Count>,
#[account(
seeds = [REMOTE_SEED, &count.key().to_bytes(), ¶ms.src_eid.to_be_bytes()],
bump = remote.bump,
constraint = params.sender == remote.address
)]
pub remote: Account<'info, Remote>,
}
impl LzReceive<'_> {
pub fn apply(ctx: &mut Context<LzReceive>, params: &LzReceiveParams) -> Result<()> {
let seeds: &[&[u8]] =
&[COUNT_SEED, &ctx.accounts.count.id.to_be_bytes(), &[ctx.accounts.count.bump]];
// **Clear the payload.**
// This step updates nonces, verifies the payload hash, and deletes the message to prevent reentrancy.
let accounts_for_clear = &ctx.remaining_accounts[0..Clear::MIN_ACCOUNTS_LEN];
let _ = oapp::endpoint_cpi::clear(
ENDPOINT_ID,
ctx.accounts.count.key(),
accounts_for_clear,
seeds,
ClearParams {
receiver: ctx.accounts.count.key(),
src_eid: params.src_eid,
sender: params.sender,
nonce: params.nonce,
guid: params.guid,
message: params.message.clone(),
},
)?;
// Execute token operations or minting if applicable.
// For a composed message, trigger the compose logic.
let msg_type = msg_codec::msg_type(¶ms.message);
match msg_type {
msg_codec::VANILLA_TYPE => ctx.accounts.count.count += 1,
msg_codec::COMPOSED_TYPE => {
ctx.accounts.count.count += 1;
oapp::endpoint_cpi::send_compose(
ENDPOINT_ID,
ctx.accounts.count.key(),
&ctx.remaining_accounts[Clear::MIN_ACCOUNTS_LEN..],
seeds,
SendComposeParams {
to: ctx.accounts.count.key(), // For example, self
guid: params.guid,
index: 0,
message: params.message.clone(),
},
)?;
},
_ => return Err(CounterError::InvalidMessageType.into()),
}
Ok(())
}
}- Clears the Payload:
Key Solana-Specific Considerations
Explicit Safety Checks:
Unlike the EVM, where safety checks such as payload clearing are handled by a provided inheritance pattern, the Solana OApp must explicitly implement these checks within itslzReceive
logic. This includes updating nonces, verifying payload integrity, and deleting processed messages to prevent reentrancy or double execution.CPI and Account Assembly:
The flow (execute
→lzReceiveTypes
→lzReceive
) relies on explicit CPI calls, with each instruction receiving a full list of pre-allocated accounts. There is no runtime dispatch or inheritance; all required accounts must be passed along manually.Token Operations:
When the message carries token transfers (as in OFT), token operations (transfer or mint) are executed withinlzReceive
via CPI calls to the Token Program.
This documentation outlines the full receive workflow on Solana, detailing the flow from message execution to final processing while emphasizing the responsibility of the OApp to implement its own safety measures within lzReceive
.