LayerZero V2 Sui Protocol Implementation
This page provides a deep technical dive into the LayerZero V2 protocol implementation on Sui, documenting the complete message lifecycle with actual contract code, function signatures, and transaction analysis.
What you'll find:
- Complete send workflow (7 steps from OApp to MessagingReceipt)
- DVN verification and commit process with storage management
- Executor delivery and OApp receive handling
- Real transaction analysis from mainnet
- Event emissions and monitoring
- Recovery operations (skip, clear, nilify, burn)
Target audience: Developers who understand Sui basics and want to deeply understand the protocol implementation.
Before reading this page, familiarize yourself with Sui fundamentals in Technical Overview. For SDK usage and practical implementation, see OFT SDK or implementation guides for OApp and OFT.
This page documents the complete message lifecycle with contract-level implementation details:
- Send Workflow: Message initiation, fee calculation, nonce management, and packet dispatch
- Verification Workflow: DVN submission, threshold checking, and verification commitment
- Receive Workflow: Executor delivery, payload clearing, and OApp processing
Send Overview
When a user sends a cross-chain message, the following high-level steps occur within a single Programmable Transaction Block (PTB):
- OApp Initiates Send: User calls the OApp module's
send()function, which creates aCall<SendParam, MessagingReceipt>object - Endpoint Processes: The Endpoint increments the outbound nonce, constructs a packet with GUID, and routes to the send library
- ULN302 Assigns Jobs: The message library creates child
Callobjects for the executor and each DVN - Workers Calculate Fees: Executor and DVNs estimate their fees and return
FeeRecipientresults - Confirmation Chain: Results flow back through confirm functions, aggregating fees and emitting events
- OApp Finalizes: The OApp confirms the send call to extract the
MessagingReceipt
Endpoint Send
The EndpointV2 module orchestrates message sending through its shared object.
EndpointV2 Shared Object
/// The main endpoint object that coordinates all cross-chain messaging operations
public struct EndpointV2 has key {
id: UID,
eid: u32, // This chain's LayerZero endpoint ID
call_cap: CallCap, // Capability for creating calls
oapp_registry: OAppRegistry, // Registry of all registered OApps
composer_registry: ComposerRegistry, // Registry for compose handlers
message_lib_manager: MessageLibManager, // Manages send/receive libraries
}
MessagingChannel per OApp
Each OApp gets a dedicated MessagingChannel shared object for parallel execution:
/// Shared object managing message channels for a specific OApp
public struct MessagingChannel has key {
id: UID,
oapp: address, // OApp owner of this channel
channels: Table<ChannelKey, Channel>, // Maps (eid, remote_oapp) → channel state
is_sending: bool, // Prevents reentrancy
}
/// Composite key identifying a specific channel path
public struct ChannelKey has copy, drop, store {
remote_eid: u32, // Destination endpoint ID
remote_oapp: Bytes32, // Remote OApp address (32 bytes)
}
/// State for a specific channel path
public struct Channel has store {
outbound_nonce: u64, // Next nonce for sends
lazy_inbound_nonce: u64, // Last cleared (executed) nonce
inbound_payload_hashes: Table<u64, Bytes32>, // Verified messages awaiting execution
}
Step 1: OApp Creates Send Call
The OApp module creates a Call object targeting the Endpoint:
/// From oapp::send()
public fun send(
self: &mut OApp,
oapp_cap: &CallCap, // Proves OApp ownership
dst_eid: u32, // Destination endpoint ID
message: vector<u8>, // Message payload
options: vector<u8>, // Execution options
native_fee: Coin<SUI>, // Fee payment in SUI
lz_token_fee: Option<Coin<ZRO>>, // Optional ZRO payment
refund_address: address, // Address for refunds
ctx: &mut TxContext,
): Call<SendParam, MessagingReceipt> {
self.assert_oapp_cap(oapp_cap);
// Lookup peer address for destination
let receiver = self.peer.get_peer(dst_eid);
// Combine enforced options with provided options
let final_options = self.enforced_options.combine_options(dst_eid, SEND_MSG_TYPE, options);
// Create send parameters
let send_param = endpoint_send::create_param(
dst_eid,
receiver,
message,
final_options,
native_fee,
lz_token_fee,
refund_address,
);
// Create Call object targeting the Endpoint
call::create(oapp_cap, endpoint!(), false, send_param, ctx)
}
Key Points:
- Returns a
Callobject (hot potato—must be consumed) - The
Callhas nodroporstoreabilities - PTB must route this
Callto the Endpoint module oapp_capvalidates ownership via ID comparison
Step 2: Endpoint Processes Send
The Endpoint receives the Call, manages state, and delegates to the send library:
/// From endpoint_v2::send()
public fun send(
self: &EndpointV2,
messaging_channel: &mut MessagingChannel,
call: &mut Call<EndpointSendParam, MessagingReceipt>,
ctx: &mut TxContext,
): Call<MessageLibSendParam, MessageLibSendResult> {
// Validate Call came from the OApp that owns this channel
call.assert_caller(messaging_channel.oapp());
// Get the configured send library for this destination
let (send_lib, _) = self.message_lib_manager.get_send_library(
call.caller(),
call.param().dst_eid()
);
// Create outbound packet with incremented nonce
// This is where the nonce++ happens:
let send_param = messaging_channel.send(self.eid(), call.param());
// Create child Call targeting the send library
call.create_single_child(&self.call_cap, send_lib, send_param, ctx)
}
Inside messaging_channel.send():
/// From messaging_channel::send()
public(package) fun send(
self: &mut MessagingChannel,
src_eid: u32,
param: &EndpointSendParam,
): MessageLibSendParam {
assert!(!self.is_sending, ESendReentrancy);
self.is_sending = true;
// Get or create the channel for this destination
let channel_key = ChannelKey {
remote_eid: param.dst_eid(),
remote_oapp: param.receiver()
};
if (!self.channels.contains(channel_key)) {
// Initialize channel if first send
let channel = Channel {
outbound_nonce: 0,
lazy_inbound_nonce: 0,
inbound_payload_hashes: table::new(ctx),
};
self.channels.add(channel_key, channel);
};
// Increment nonce
let channel = &mut self.channels[channel_key];
channel.outbound_nonce = channel.outbound_nonce + 1;
// Build packet with GUID
let packet = outbound_packet::create(
channel.outbound_nonce,
src_eid,
self.oapp, // Sender address
param.dst_eid(),
param.receiver(),
param.message(),
);
// Create send parameters for message library
message_lib_send::create_param(
packet,
param.options(),
param.pay_in_zro(),
param.native_fee_ref(),
param.lz_token_fee_ref(),
)
}
GUID Generation:
Uses keccak256 hashing with BCS-encoded parameters:
/// From outbound_packet module
public fun create(...): OutboundPacket {
let guid = hash::keccak256!(
&vector[
nonce.to_be_bytes(), // Convert to bytes (big-endian)
src_eid.to_be_bytes(),
sender.to_bytes(),
dst_eid.to_be_bytes(),
receiver.data(),
message,
]
);
OutboundPacket { nonce, src_eid, sender, dst_eid, receiver, guid, message }
}
Step 3: ULN302 Assigns Jobs to Workers
The ULN302 message library creates child Call objects for the executor and DVNs:
/// From uln_302::send()
public fun send(
self: &Uln302,
call: &mut Call<MessageLibSendParam, MessageLibSendResult>,
ctx: &mut TxContext,
): (Call<ExecutorAssignJobParam, FeeRecipient>, MultiCall<DvnAssignJobParam, FeeRecipient>) {
call.assert_caller(endpoint!());
assert!(self.is_supported_eid(call.param().base().packet().dst_eid()), EUnsupportedEid);
// Get executor and DVN parameters from SendUln
let (executor, executor_param, dvns, dvn_params) = self.send_uln.send(call.param());
// Create a new child batch (capacity: 1 executor + N DVNs)
call.new_child_batch(&self.call_cap, 1);
// Create child Call for each DVN
let dvn_calls = dvns.zip_map!(dvn_params, |dvn, param|
call.create_child(&self.call_cap, dvn, param, false, ctx)
);
// Create child Call for executor (marked as last child)
let executor_call = call.create_child(&self.call_cap, executor, executor_param, true, ctx);
// Return executor call and MultiCall wrapper for DVN calls
(executor_call, multi_call::create(&self.call_cap, dvn_calls))
}
Inside send_uln::send():
/// From send_uln::send() - prepares worker parameters
public(package) fun send(
self: &SendUln,
param: &MessageLibSendParam,
): (address, ExecutorAssignJobParam, vector<address>, vector<DvnAssignJobParam>) {
let packet = param.base().packet();
let sender = packet.sender();
let dst_eid = packet.dst_eid();
// Get effective executor configuration (OApp-specific or default)
let executor_config = self.get_executor_config(sender, dst_eid);
// Validate message size
assert!(packet.message().length() <= executor_config.max_message_size(), EInvalidMessageSize);
// Split options into executor and DVN options
let (executor_options, dvn_options) = worker_options::split_worker_options(param.base().options());
// Create executor job parameters
let executor_param = executor_assign_job::create_param(
packet.guid(),
packet.dst_eid(),
sender,
packet.message().length(),
executor_options,
);
// Get effective ULN configuration (OApp-specific or default)
let uln_config = self.get_uln_config(sender, dst_eid);
// Encode packet header for DVN verification
let packet_header = packet_v1_codec::encode_packet_header(packet);
let payload_hash = packet_v1_codec::payload_hash(packet);
// Create DVN job parameters for each configured DVN
let (dvns, dvn_params) = self.create_dvn_params(
packet.guid(),
packet.dst_eid(),
sender,
packet_header,
payload_hash,
uln_config,
dvn_options,
);
(executor_config.executor(), executor_param, dvns, dvn_params)
}
Step 4: Workers Process Job Assignments
Executor Assignment:
/// From executor::assign_job()
public fun assign_job(
self: &Executor,
call: &mut Call<AssignJobParam, FeeRecipient>,
ctx: &mut TxContext,
): Call<FeelibGetFeeParam, u64> {
// Extract parameters
let param = *call.param().base();
// Create child call to fee library for fee calculation
self.create_feelib_get_fee_call(call, param, ctx)
}
/// Executor confirms fee calculation
public fun confirm_assign_job(
self: &Executor,
executor_call: &mut Call<AssignJobParam, FeeRecipient>,
feelib_call: Call<FeelibGetFeeParam, u64>,
) {
// Destroy fee library call and extract fee
let (_, _, fee) = executor_call.destroy_child(self.worker.worker_cap(), feelib_call);
// Complete executor call with FeeRecipient
executor_call.complete(
self.worker.worker_cap(),
fee_recipient::create(fee, self.worker.deposit_address())
);
}
DVN Assignment (similar pattern):
/// From dvn::assign_job()
public fun assign_job(
self: &DVN,
call: &mut Call<DvnAssignJobParam, FeeRecipient>,
ctx: &mut TxContext,
): Call<FeelibGetFeeParam, u64> {
let param = *call.param().base();
self.create_feelib_get_fee_call(call, param, ctx)
}
Step 5: ULN302 Confirms Send
The ULN302 destroys worker Call objects and aggregates fees:
/// From uln_302::confirm_send()
public fun confirm_send(
self: &Uln302,
endpoint: &EndpointV2,
treasury: &Treasury,
messaging_channel: &mut MessagingChannel,
endpoint_call: &mut Call<EndpointSendParam, MessagingReceipt>,
mut send_library_call: Call<MessageLibSendParam, MessageLibSendResult>,
executor_call: Call<ExecutorAssignJobParam, FeeRecipient>,
dvn_multi_call: MultiCall<DvnAssignJobParam, FeeRecipient>,
ctx: &mut TxContext,
) {
send_library_call.assert_caller(endpoint!());
// Destroy DVN calls and collect fee recipients
let (mut dvns, mut dvn_recipients) = (vector[], vector[]);
dvn_multi_call.destroy(&self.call_cap).do!(|dvn_call| {
let (dvn, _, dvn_recipient) = send_library_call.destroy_child(&self.call_cap, dvn_call);
dvns.push_back(dvn);
dvn_recipients.push_back(dvn_recipient);
});
// Destroy executor call and collect fee recipient
let (executor, _, executor_recipient) = send_library_call.destroy_child(&self.call_cap, executor_call);
// Calculate total fees and encoded packet
let send_result = send_uln::confirm_send(
send_library_call.param(),
executor,
executor_recipient,
dvns,
dvn_recipients,
treasury,
);
send_library_call.complete(&self.call_cap, send_result);
// Call endpoint for final confirmation
let (native_token, zro_token) = endpoint.confirm_send(
&self.call_cap,
messaging_channel,
endpoint_call,
send_library_call,
ctx,
);
// Distribute fees to workers
send_uln::handle_fees(treasury, executor_recipient, dvn_recipients, native_token, zro_token, ctx);
}
Inside send_uln::confirm_send():
public(package) fun confirm_send(
param: &MessageLibSendParam,
executor: address,
executor_recipient: FeeRecipient,
dvns: vector<address>,
dvn_recipients: vector<FeeRecipient>,
treasury: &Treasury,
): MessageLibSendResult {
let packet = param.base().packet();
// Aggregate worker fees
let mut native_recipients = vector[executor_recipient];
native_recipients.append(dvn_recipients);
let total_native_fee = native_recipients.fold!(0, |acc, r| acc + r.fee());
// Calculate treasury fee
let (treasury_recipient, zro_recipient) = treasury.quote_treasury_fee(
packet.sender(),
packet.dst_eid(),
total_native_fee,
param.base().pay_in_zro()
);
if (treasury_recipient.fee() > 0) {
native_recipients.push_back(treasury_recipient);
};
let zro_recipients = if (zro_recipient.fee() > 0) {
vector[zro_recipient]
} else {
vector[]
};
// Encode packet for event emission
let encoded_packet = packet_v1_codec::encode_packet(packet);
// Emit events
event::emit(ExecutorFeePaidEvent { guid: packet.guid(), executor, fee: executor_recipient });
event::emit(DVNFeePaidEvent { guid: packet.guid(), dvns, fees: dvn_recipients });
// Return result with fee recipients
message_lib_send::create_result(encoded_packet, native_recipients, zro_recipients)
}
Step 6: Endpoint Finalizes Send
/// From endpoint_v2::confirm_send()
public fun confirm_send(
self: &EndpointV2,
send_library: &CallCap, // Library's capability (static call)
messaging_channel: &mut MessagingChannel,
endpoint_call: &mut Call<EndpointSendParam, MessagingReceipt>,
send_library_call: Call<MessageLibSendParam, MessageLibSendResult>,
ctx: &mut TxContext,
): (Coin<SUI>, Coin<ZRO>) {
messaging_channel.assert_ownership(endpoint_call.caller());
// Destroy library call and extract results
let (send_lib, param, result) = endpoint_call.destroy_child(&self.call_cap, send_library_call);
assert!(send_lib == send_library.id(), EUnauthorizedSendLibrary);
// Process fee payment and emit events
let (receipt, paid_native_token, paid_zro_token) = messaging_channel.confirm_send(
send_lib,
endpoint_call.param_mut(&self.call_cap),
param,
result,
ctx,
);
// Complete the Call with MessagingReceipt
endpoint_call.complete(&self.call_cap, receipt);
// Return collected fees for distribution
(paid_native_token, paid_zro_token)
}
Inside messaging_channel.confirm_send():
public(package) fun confirm_send(
self: &mut MessagingChannel,
send_library: address,
endpoint_param: &mut EndpointSendParam,
message_lib_param: MessageLibSendParam,
result: MessageLibSendResult,
ctx: &mut TxContext,
): (MessagingReceipt, Coin<SUI>, Coin<ZRO>) {
// Extract fee recipients
let (encoded_packet, native_recipients, zro_recipients) = result.destroy();
// Calculate total fees required
let total_native_fee = native_recipients.fold!(0, |acc, r| acc + r.fee());
let total_zro_fee = zro_recipients.fold!(0, |acc, r| acc + r.fee());
// Split coins from endpoint_param
let paid_native = coin::split(endpoint_param.native_fee_mut(), total_native_fee, ctx);
let paid_zro = if (total_zro_fee > 0) {
coin::split(endpoint_param.lz_token_fee_mut().borrow_mut(), total_zro_fee, ctx)
} else {
coin::zero(ctx)
};
// Emit PacketSentEvent
event::emit(PacketSentEvent {
encoded_packet,
options: *message_lib_param.base().options(),
send_library,
});
// Create receipt
let packet = message_lib_param.base().packet();
let receipt = messaging_receipt::create(packet.guid(), packet.nonce(), total_native_fee, total_zro_fee);
// Reset sending flag
self.is_sending = false;
(receipt, paid_native, paid_zro)
}
Step 7: OApp Extracts Receipt
The OApp confirms the send call to extract the receipt:
/// From oapp::confirm_lz_send()
public fun confirm_lz_send(
self: &OApp,
oapp_cap: &CallCap,
call: Call<SendParam, MessagingReceipt>,
): (SendParam, MessagingReceipt) {
self.assert_oapp_cap(oapp_cap);
// Destroy the Call and extract results
let (endpoint, param, receipt) = call.destroy(oapp_cap);
assert!(endpoint == endpoint!(), EOnlyEndpoint);
(param, receipt)
}
Example PTB for Send (from actual transaction)
Based on transaction HXZqH1RdANEkstz3MTFGMuLQ74CfgAkwQCq1YW8TMHHH:
// PTB commands in order:
1. SplitCoins - Split fee from sender's SUI
2. MoveCall - bytes32::from_bytes (convert recipient to Bytes32)
3. MoveCall - send_param::create (create SendParam struct)
4. MoveCall - oft_sender::tx_sender (create OFTSender context)
5. SplitCoins - Split token amount from sender's coin
6. MoveCall - oft::send (initiate OFT send, returns Call)
7. MoveCall - endpoint::send (route Call to endpoint)
8. MoveCall - uln_302::send (create worker calls)
9. MoveCall - executor::assign_job (executor processes)
10. MoveCall - dvn::assign_job (each DVN processes)
11. MoveCall - executor::confirm_assign_job
12. MoveCall - dvn::confirm_assign_job (for each DVN)
13. MoveCall - uln_302::confirm_send
14. MoveCall - oft::confirm_send (extract receipt)
// Events emitted:
- ExecutorFeePaidEvent
- DVNFeePaidEvent
- PacketSentEvent
- OFTSentEvent
Send Limitations
Max Message Size
The maxMessageSize is configured per executor and OApp:
/// From ExecutorConfig struct
public struct ExecutorConfig has copy, drop, store {
executor: address, // Executor address
max_message_size: u64, // Maximum message bytes (default varies by network)
}
Default is typically 10,000 bytes, but OApps can configure custom limits.
Fee Payment Model
Unlike EVM's direct fee transfer, Sui uses Coin object splitting:
// Split exact fee amount from provided coins
let paid_native = coin::split(native_fee_coin, required_fee, ctx);
// Transfer to fee recipient
transfer::public_transfer(paid_native, recipient_address);
// Refund excess
transfer::public_transfer(remaining_coin, refund_address);
Verification Workflow
After the PacketSentEvent is emitted on the source chain, DVNs independently verify the message on the destination chain.
DVN Verification Process
Step 1: DVN Monitors Source Chain
DVNs watch for PacketSentEvent and wait for the configured number of block confirmations (finality).
Step 2: DVN Submits Verification
Each DVN calls the verify() function on the ULN302:
/// From uln_302::verify()
public fun verify(
self: &Uln302,
verification: &mut Verification, // Shared verification object
call: Call<DvnVerifyParam, Void>,
) {
let dvn = call.caller(); // DVN's CallCap proves identity
let param = call.complete_and_destroy(&self.call_cap);
// Store verification in the Verification shared object
receive_uln::verify(
verification,
dvn,
*param.packet_header(),
param.payload_hash(),
param.confirmations()
)
}
Inside receive_uln::verify():
/// From receive_uln::verify()
public(package) fun verify(
self: &mut Verification,
dvn: address,
packet_header: vector<u8>,
payload_hash: Bytes32,
confirmations: u64,
) {
// Create confirmation key
let key = ConfirmationKey {
header_hash: hash::keccak256!(&packet_header),
payload_hash,
dvn,
};
// Store confirmations in Table
table_ext::upsert!(&mut self.confirmations, key, confirmations);
// Emit event for monitoring
event::emit(PayloadVerifiedEvent {
dvn,
header: packet_header,
confirmations,
proof_hash: payload_hash,
});
}
Verification Storage:
/// Shared object storing all DVN confirmations
public struct Verification has key {
id: UID,
// Maps (header_hash, payload_hash, dvn) → confirmation_count
confirmations: Table<ConfirmationKey, u64>,
}
Commit Verification
After sufficient DVNs have verified (meeting the X of Y of N threshold), anyone can call commit_verification():
/// From uln_302::commit_verification()
public fun commit_verification(
self: &Uln302,
endpoint: &EndpointV2,
verification: &mut Verification,
messaging_channel: &mut MessagingChannel,
packet_header: vector<u8>,
payload_hash: Bytes32,
clock: &Clock,
) {
// Verify and reclaim storage from Verification object
let header = self.receive_uln.verify_and_reclaim_storage(
verification,
endpoint.eid(),
packet_header,
payload_hash,
);
// Call endpoint to insert verified payload hash
endpoint.verify(
&self.call_cap, // Library capability
messaging_channel, // Destination OApp's channel
header.src_eid(), // Source endpoint ID
header.sender(), // Source OApp address
header.nonce(), // Message nonce
payload_hash, // Payload hash
clock, // For timeout validation
);
}
Inside receive_uln::verify_and_reclaim_storage():
public(package) fun verify_and_reclaim_storage(
self: &ReceiveUln,
verification: &mut Verification,
local_eid: u32,
encoded_packet_header: vector<u8>,
payload_hash: Bytes32,
): PacketHeader {
// Decode and validate packet header
let header = packet_v1_codec::decode_header(encoded_packet_header);
assert!(header.dst_eid() == local_eid, EInvalidEid);
let header_hash = hash::keccak256!(&encoded_packet_header);
let receiver = header.receiver();
let src_eid = header.src_eid();
// Get effective ULN configuration
let uln_config = self.get_uln_config(receiver, src_eid);
// Check all required DVNs have verified
let mut verified_count = 0;
uln_config.required_dvns().do!(|dvn| {
let key = ConfirmationKey { header_hash, payload_hash, dvn: *dvn };
assert!(verification.confirmations.contains(key), EVerifying);
// Remove confirmation (reclaim storage)
verification.confirmations.remove(key);
verified_count = verified_count + 1;
});
// Check optional DVN threshold is met
if (uln_config.optional_dvn_count() > 0) {
let mut optional_verified = 0;
uln_config.optional_dvns().do!(|dvn| {
let key = ConfirmationKey { header_hash, payload_hash, dvn: *dvn };
if (verification.confirmations.contains(key)) {
verification.confirmations.remove(key);
optional_verified = optional_verified + 1;
};
});
assert!(optional_verified >= uln_config.optional_dvn_threshold(), EVerifying);
};
header
}
Endpoint Verify
The Endpoint inserts the verified payload hash into the messaging channel:
/// From endpoint_v2::verify()
public fun verify(
self: &EndpointV2,
receive_library: &CallCap, // Library's capability
messaging_channel: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
payload_hash: Bytes32,
clock: &Clock,
) {
// Validate receive library is authorized
self.message_lib_manager.assert_receive_library(
messaging_channel.oapp(),
src_eid,
receive_library.id(),
clock
);
// Insert payload hash into messaging channel
messaging_channel.verify(src_eid, sender, nonce, payload_hash);
}
Inside messaging_channel::verify():
public(package) fun verify(
self: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
payload_hash: Bytes32,
) {
assert!(payload_hash != EMPTY_PAYLOAD_HASH, EInvalidPayloadHash);
// Get or create channel for this pathway
let channel_key = ChannelKey { remote_eid: src_eid, remote_oapp: sender };
if (!self.channels.contains(channel_key)) {
self.init_channel(src_eid, sender);
};
// Insert payload hash into the channel
let channel = &mut self.channels[channel_key];
channel.inbound_payload_hashes.add(nonce, payload_hash);
// Emit verification event
event::emit(PacketVerifiedEvent {
src_eid,
sender,
nonce,
receiver: self.oapp,
payload_hash,
});
}
Message State Transition:
Send → PacketSentEvent emitted on source chain
↓
DVNs monitor and verify (off-chain)
↓
DVNs call verify() (on-chain submission)
↓
Verification confirmations stored in Verification object
↓
commit_verification() checks threshold
↓
Payload hash inserted into MessagingChannel
↓
Message ready for execution
Receive Workflow
After verification is committed, the Executor can deliver the message to the destination OApp.
Executor Delivery
The Executor initiates message delivery by constructing a PTB with all required objects.
Step 1: Executor Queries OApp Metadata
The Executor queries the OApp's execution metadata to determine which objects are needed:
// OApp implements this to provide execution metadata
public fun get_oapp_info(oapp: &OApp): vector<u8> {
// Returns encoded OAppInfoV1 containing required Move calls
}
Step 2: Executor Creates PTB
Based on the transaction 9fqmkJYFQyQs6u1vVmMSuqhZyobpSW7P4i7MaNVzbSFg, the PTB contains:
1. MoveCall - bytes32::from_bytes (decode sender)
2. MoveCall - bytes32::from_bytes (decode receiver)
3. MoveCall - option::none<Coin<SUI>> (no value transfer)
4. MoveCall - executor_worker::execute_lz_receive (entry point)
5. MoveCall - counter::lz_receive (OApp business logic)
Objects passed:
- Executor shared object (immutable reference)
- Executor capability (owned object)
- EndpointV2 shared object (immutable reference)
- MessagingChannel shared object (mutable reference)
- Clock object (for validation)
- Counter OApp shared object (mutable reference)
- Counter Peer object (immutable reference)
Step 3: Executor Calls execute_lz_receive
/// From executor::execute_lz_receive()
public fun execute_lz_receive(
self: &Executor,
endpoint: &EndpointV2,
messaging_channel: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
guid: Bytes32,
message: vector<u8>,
extra_data: vector<u8>,
value: Option<Coin<SUI>>,
ctx: &mut TxContext,
): Call<LzReceiveParam, Void> {
// Create lz_receive call via endpoint
endpoint.lz_receive(
&self.worker.worker_cap(), // Executor's capability
messaging_channel,
src_eid,
sender,
nonce,
guid,
message,
extra_data,
value,
ctx,
)
}
Step 4: Endpoint Creates lz_receive Call
/// From endpoint_v2::lz_receive()
public fun lz_receive(
self: &EndpointV2,
executor: &CallCap, // Executor's capability
messaging_channel: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
guid: Bytes32,
message: vector<u8>,
extra_data: vector<u8>,
value: Option<Coin<SUI>>,
ctx: &mut TxContext,
): Call<LzReceiveParam, Void> {
// Clear the payload first (prevents reentrancy)
messaging_channel.clear(src_eid, sender, nonce, guid, &message);
// Create lz_receive parameters
let lz_receive_param = lz_receive::create_param(
src_eid,
sender,
nonce,
guid,
message,
extra_data,
value,
);
// Create Call object targeting the OApp
call::create(
executor, // Executor creates the Call
messaging_channel.oapp(), // Target: OApp address
true, // One-way call (no result expected)
lz_receive_param,
ctx,
)
}
Inside messaging_channel::clear():
public(package) fun clear(
self: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
guid: Bytes32,
message: &vector<u8>,
) {
let channel_key = ChannelKey { remote_eid: src_eid, remote_oapp: sender };
let channel = &mut self.channels[channel_key];
// Lazy nonce update: clear all messages up to this nonce
if (nonce > channel.lazy_inbound_nonce) {
let mut i = channel.lazy_inbound_nonce + 1;
while (i <= nonce) {
assert!(channel.inbound_payload_hashes.contains(i), EInvalidNonce);
i = i + 1;
};
channel.lazy_inbound_nonce = nonce;
};
// Verify payload hash matches verified hash
let expected_hash = channel.inbound_payload_hashes[nonce];
let actual_hash = hash::keccak256!(&vector[guid.data(), *message]);
assert!(expected_hash == actual_hash, EPayloadHashNotFound);
// Remove from storage (prevents double execution)
channel.inbound_payload_hashes.remove(nonce);
// Emit delivery event
event::emit(PacketDeliveredEvent {
src_eid,
sender,
receiver: self.oapp,
nonce,
});
}
Key Security Features:
- Lazy nonce validation: Ensures all prior messages have been verified
- Payload hash verification: Confirms executor provided the correct message
- Storage cleanup: Removes hash to prevent double execution
- Event emission: Signals successful delivery
Step 5: OApp Processes Message
The OApp's lz_receive() function is invoked via the Call object:
/// Example from counter OApp
public fun lz_receive(
self: &mut Counter,
peer: &Peer,
call: Call<LzReceiveParam, Void>,
) {
// Validate Call came from Endpoint
let (callee, param, _) = call.complete_and_destroy(&self.call_cap);
assert!(callee == endpoint_address(), EOnlyEndpoint);
// Validate sender is configured peer
assert!(param.sender() == peer.address, EOnlyPeer);
// Process message (application-specific logic)
self.count = self.count + 1;
// Note: No need to manually call clear() - already done by Endpoint
}
OApp Responsibilities:
-
- Validate
Callcame from authorized Endpoint
- Validate
-
- Validate sender matches configured peer
-
- Process message and update state
-
- No need to call
clear()(done by Endpoint before Call creation)
- No need to call
Example PTB for Receive (from actual transaction)
Based on transaction 9fqmkJYFQyQs6u1vVmMSuqhZyobpSW7P4i7MaNVzbSFg:
// PTB commands in order:
1. MoveCall - bytes32::from_bytes (decode sender parameter)
2. MoveCall - bytes32::from_bytes (decode guid parameter)
3. MoveCall - option::none<Coin<SUI>> (no native token transfer)
4. MoveCall - executor_worker::execute_lz_receive
- Passes: Executor, Endpoint, MessagingChannel, src_eid, sender, nonce, guid, message
- Returns: Call<LzReceiveParam, Void>
5. MoveCall - counter::lz_receive
- Receives the Call object
- Validates and processes
- Destroys the Call
Objects used:
- 0x5f24...0c8e: Executor shared object (immutable)
- 0x00a7...9fc2: Executor CallCap (owned)
- 0xd45b...bf91: EndpointV2 shared object (immutable)
- 0x9b01...1843: MessagingChannel shared object (mutable)
- 0x6903...8c4d: Counter OApp shared object (mutable)
- 0x224b...fbb3: Counter Peer shared object (immutable)
- 0x608a...6f27: Counter's internal state (mutable)
// Events emitted:
- PacketDeliveredEvent
Key Sui-Specific Patterns
Object Ownership in Message Flow
| Object Type | Ownership | Access Pattern | Example |
|---|---|---|---|
EndpointV2 | Shared | Anyone reads, admin writes | &EndpointV2 or &mut EndpointV2 |
MessagingChannel | Shared | Anyone reads, owner writes | &mut MessagingChannel |
OApp | Shared | Anyone reads, admin writes | &mut OApp |
CallCap | Owned | Must own to use | Owned by OApp module or user |
AdminCap | Owned | Must own to use | Owned by admin address |
Call<P, R> | Neither | Must be consumed in PTB | Created and destroyed in same TX |
Call Pattern vs EVM/Solana
| Aspect | EVM | Solana | Sui |
|---|---|---|---|
| Cross-Contract Calls | delegatecall | CPI (Cross-Program Invocation) | Call<Param, Result> objects |
| Authorization | msg.sender | Signer checks + PDAs | CallCap validation |
| Return Values | Function returns | CPI returns | Call.complete() sets result |
| Call Hierarchy | Call stack (implicit) | CPI depth limit (4) | Call parent/child relationships |
| Atomicity | Transaction revert | Transaction revert | PTB revert |
Nonce Management
EVM:
// Mapping-based nonce storage
mapping(address => mapping(uint32 => mapping(bytes32 => uint64))) outboundNonce;
Solana:
// PDA account per pathway
#[account(seeds = [NONCE_SEED, sender, dst_eid, receiver], bump)]
pub nonce: Account<'info, Nonce>,
Sui:
// Table within MessagingChannel, nested in Channel struct
public struct MessagingChannel has key {
channels: Table<ChannelKey, Channel>, // Maps (eid, remote_oapp) → Channel
}
public struct Channel has store {
outbound_nonce: u64, // Incremented on each send
lazy_inbound_nonce: u64, // Last executed inbound nonce
inbound_payload_hashes: Table<u64, Bytes32>, // Maps nonce → hash
}
Fee Payment Model
EVM:
// Direct transfer in msg.value
Transfer.native(executor, executorFee);
Solana:
// Token account transfer
transfer(from_account, to_account, amount);
Sui:
// Coin object splitting and transfer
let fee_coin = coin::split(&mut provided_coin, fee_amount, ctx);
transfer::public_transfer(fee_coin, recipient_address);
Configuration Management
Send Library Configuration
OApps can set custom send libraries per destination:
/// From endpoint_v2 (called by OApp with AdminCap)
public fun set_send_library(
self: &mut EndpointV2,
caller: &CallCap,
oapp: address,
dst_eid: u32,
new_lib: address,
) {
self.assert_authorized(caller.id(), oapp);
self.message_lib_manager.set_send_library(oapp, dst_eid, new_lib);
}
Default Fallback:
/// From message_lib_manager
public(package) fun get_send_library(
self: &MessageLibManager,
sender: address,
dst_eid: u32,
): (address, bool) {
// Try OApp-specific config first
let key = SendLibraryKey { sender, dst_eid };
if (self.send_libraries.contains(key)) {
return (self.send_libraries[key], false) // Custom library
};
// Fall back to default
let default_lib = self.default_send_libraries[dst_eid];
(default_lib, true) // Default library
}
DVN Configuration
OApps configure DVN sets through the ULN:
/// ULN configuration structure
public struct UlnConfig has copy, drop, store {
confirmations: u64, // Block confirmations required
required_dvn_count: u8, // Number of required DVNs
optional_dvn_count: u8, // Number of optional DVNs
optional_dvn_threshold: u8, // How many optional DVNs must verify
required_dvns: vector<address>, // Required DVN addresses
optional_dvns: vector<address>, // Optional DVN addresses
}
Setting Configuration (via Endpoint):
/// From endpoint_v2::set_config()
public fun set_config(
self: &mut EndpointV2,
caller: &CallCap, // OApp or delegate
oapp: address,
config_type: u32,
eid: u32,
config: vector<u8>,
ctx: &mut TxContext,
) {
self.assert_authorized(caller.id(), oapp);
// Get message library
let (lib, _) = if (is_send_config(config_type)) {
self.message_lib_manager.get_send_library(oapp, eid)
} else {
self.message_lib_manager.get_receive_library(oapp, eid, clock)
};
// Create Call to library's set_config
let set_config_param = message_lib_set_config::create_param(oapp, config_type, eid, config);
let call = call::create(caller, lib, true, set_config_param, ctx);
// Library processes configuration immediately (one-way call)
// No confirmation needed
}
Recovery Operations
The Endpoint provides several recovery mechanisms for stuck or problematic messages.
Skip
Increments the lazy nonce without executing the message:
/// From endpoint_v2::skip()
public fun skip(
self: &EndpointV2,
caller: &CallCap,
messaging_channel: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
) {
self.assert_authorized(caller.id(), messaging_channel.oapp());
messaging_channel.skip(src_eid, sender, nonce);
}
Inside messaging_channel::skip():
public(package) fun skip(
self: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
) {
let channel_key = ChannelKey { remote_eid: src_eid, remote_oapp: sender };
let channel = &mut self.channels[channel_key];
// Validate nonce is next expected
assert!(nonce == channel.lazy_inbound_nonce + 1, EInvalidNonce);
// Increment lazy nonce (skipping this message)
channel.lazy_inbound_nonce = nonce;
event::emit(InboundNonceSkippedEvent {
src_eid,
sender,
receiver: self.oapp,
nonce,
});
}
Nilify
Removes verification but keeps nonce ordering:
public fun nilify(
self: &EndpointV2,
caller: &CallCap,
messaging_channel: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
payload_hash: Bytes32,
) {
self.assert_authorized(caller.id(), messaging_channel.oapp());
messaging_channel.nilify(src_eid, sender, nonce, payload_hash);
}
Burn
Permanently blocks a nonce (irreversible):
public fun burn(
self: &EndpointV2,
caller: &CallCap,
messaging_channel: &mut MessagingChannel,
src_eid: u32,
sender: Bytes32,
nonce: u64,
payload_hash: Bytes32,
) {
self.assert_authorized(caller.id(), messaging_channel.oapp());
messaging_channel.burn(src_eid, sender, nonce, payload_hash);
}
Comparison with EVM and Solana
Architecture Comparison
| Component | EVM | Solana | Sui |
|---|---|---|---|
| Code Organization | Solidity contracts | Rust programs | Move packages/modules |
| State Storage | Contract storage slots | PDA accounts | Shared/owned objects |
| Nonce Management | Nested mappings | PDA per pathway | Table in MessagingChannel |
| Message Channel | Contract storage | PDA accounts | MessagingChannel shared object |
| Call Pattern | delegatecall | CPI | Call<Param, Result> objects |
| Authorization | msg.sender | Signers + PDA derivation | CallCap validation |
| Fee Payment | msg.value transfer | Token account ops | Coin object splitting |
| Atomicity | Transaction revert | Transaction revert | PTB revert |
Send Flow Comparison
| Step | EVM | Solana | Sui |
|---|---|---|---|
| Initiate | OApp.send() internal | OApp CPI to Endpoint | OApp creates Call object |
| Nonce | Mapping increment | PDA account write | Table field increment |
| Library Call | Direct function call | CPI to SendUln302 | Child Call creation |
| Worker Calls | Direct function calls | CPI to each worker | Child Call per worker |
| Fee Collection | Transfer to library | Record in library | Coin splitting |
| Event | emit PacketSent | emit_cpi!(PacketSent) | event::emit(PacketSent) |
Receive Flow Comparison
| Step | EVM | Solana | Sui |
|---|---|---|---|
| Entry Point | Executor calls Endpoint.lzReceive | Executor invokes with all accounts | Executor calls execute_lz_receive |
| Clear Payload | Endpoint clears before calling OApp | OApp CPIs back to Endpoint.clear | Endpoint clears before creating Call |
| OApp Invocation | delegatecall to OApp.lzReceive | Instruction with account list | Call object to OApp module |
| Validation | Modifier checks | Account constraints + CPI auth | Call validation + peer check |
| Processing | Override _lzReceive | Implement lz_receive instruction | Implement lz_receive function |
Event Monitoring
Events Emitted During Send
- ExecutorFeePaidEvent (from ULN):
public struct ExecutorFeePaidEvent has copy, drop {
guid: Bytes32,
executor: address,
fee: FeeRecipient,
}
- DVNFeePaidEvent (from ULN):
public struct DVNFeePaidEvent has copy, drop {
guid: Bytes32,
dvns: vector<address>,
fees: vector<FeeRecipient>,
}
- PacketSentEvent (from MessagingChannel):
public struct PacketSentEvent has copy, drop {
encoded_packet: vector<u8>, // Full packet with header + payload
options: vector<u8>, // Execution options
send_library: address, // Library that processed send
}
- OFTSentEvent (from OFT, if applicable):
public struct OFTSentEvent has copy, drop {
guid: Bytes32,
dst_eid: u32,
from_address: address,
amount_sent_ld: u64, // Amount in local decimals
amount_received_ld: u64, // Amount after dust removal
}
Events Emitted During Verification
- PayloadVerifiedEvent (per DVN):
public struct PayloadVerifiedEvent has copy, drop {
dvn: address,
header: vector<u8>,
confirmations: u64,
proof_hash: Bytes32,
}
- PacketVerifiedEvent (after commit):
public struct PacketVerifiedEvent has copy, drop {
src_eid: u32,
sender: Bytes32,
nonce: u64,
receiver: address,
payload_hash: Bytes32,
}
Events Emitted During Receive
- PacketDeliveredEvent:
public struct PacketDeliveredEvent has copy, drop {
src_eid: u32,
sender: Bytes32,
receiver: address,
nonce: u64,
}
- OFTReceivedEvent (from OFT, if applicable):
public struct OFTReceivedEvent has copy, drop {
guid: Bytes32,
src_eid: u32,
to_address: address,
amount_received_ld: u64,
}
Capabilities and Authorization
CallCap Pattern
CallCap is Sui's capability-based authorization for creating Call objects:
/// From call_cap module
public struct CallCap has key, store {
id: UID,
package_id: address, // Package that owns this capability
}
/// Create a package-level CallCap using one-time witness
public fun new_package_cap<T: drop>(otw: &T, ctx: &mut TxContext): CallCap {
CallCap {
id: object::new(ctx),
package_id: package::from_witness(otw),
}
}
Usage in Validation:
/// OApp validates its CallCap
fun assert_oapp_cap(self: &OApp, cap: &CallCap) {
assert!(self.oapp_cap.id() == cap.id(), EInvalidOAppCap);
}
/// Endpoint validates library CallCap
fun assert_send_library(lib_cap: &CallCap, expected_lib: address) {
assert!(lib_cap.id() == expected_lib, EUnauthorizedSendLibrary);
}
AdminCap Pattern
AdminCap authorizes administrative operations:
public struct AdminCap has key, store {
id: UID,
}
/// Setting a peer requires AdminCap
public fun set_peer(
self: &mut OApp,
admin_cap: &AdminCap, // Must own this object
eid: u32,
peer: Bytes32,
) {
// AdminCap ownership proves authorization
self.peer.set_peer(self.oapp_object_address(), eid, peer);
}
Ownership Transfer:
// Transfer AdminCap to new admin
transfer::public_transfer(admin_cap, new_admin_address);
PTB Construction Patterns
Simple Send PTB
const tx = new Transaction();
// 1. Split fee from gas coin
const [feeCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(feeAmount)]);
// 2. Create send parameters
const sendParam = tx.moveCall({
target: `${oappPackage}::oapp::create_send_param`,
arguments: [
tx.pure.u32(dstEid),
tx.pure.vector('u8', receiverBytes),
tx.pure.vector('u8', messageBytes),
tx.pure.vector('u8', optionsBytes),
feeCoin,
tx.pure.option('object', null), // No ZRO payment
tx.pure.address(refundAddress),
],
});
// 3. Call OApp send (returns Call object)
const sendCall = tx.moveCall({
target: `${oappPackage}::oapp::send`,
arguments: [tx.object(oappObjectId), tx.object(callCapObjectId), sendParam],
});
// 4. Route through Endpoint (processes Call)
const libCall = tx.moveCall({
target: `${endpointPackage}::endpoint_v2::send`,
arguments: [tx.object(endpointObjectId), tx.object(messagingChannelId), sendCall],
});
// 5-N. Worker assignments (handled by PTB builder)
// N+1. Confirm send (destroys Call, extracts receipt)
tx.moveCall({
target: `${oappPackage}::oapp::confirm_lz_send`,
arguments: [
tx.object(oappObjectId),
tx.object(callCapObjectId),
sendCall, // Original Call object (now completed)
],
});
await client.signAndExecuteTransaction({transaction: tx});
Receive PTB
const tx = new Transaction();
// 1. Decode parameters
const senderBytes32 = tx.moveCall({
target: `${utilsPackage}::bytes32::from_bytes`,
arguments: [tx.pure.vector('u8', senderBytes)],
});
const guidBytes32 = tx.moveCall({
target: `${utilsPackage}::bytes32::from_bytes`,
arguments: [tx.pure.vector('u8', guidBytes)],
});
// 2. Create empty value option (no native transfer)
const noValue = tx.moveCall({
target: '0x1::option::none',
typeArguments: ['0x2::coin::Coin<0x2::sui::SUI>'],
arguments: [],
});
// 3. Execute lz_receive via Executor
const lzReceiveCall = tx.moveCall({
target: `${executorPackage}::executor_worker::execute_lz_receive`,
arguments: [
tx.object(executorObjectId), // Executor shared object
tx.object(executorCapId), // Executor CallCap
tx.object(endpointObjectId), // Endpoint shared object
tx.object(messagingChannelId), // Messaging channel
tx.pure.u32(srcEid),
senderBytes32,
tx.pure.u64(nonce),
guidBytes32,
tx.pure.vector('u8', messageBytes),
tx.pure.vector('u8', extraDataBytes),
noValue,
],
});
// 4. OApp processes (receives Call object from step 3)
tx.moveCall({
target: `${oappPackage}::counter::lz_receive`,
arguments: [
tx.object(counterObjectId), // OApp shared object
tx.object(peerObjectId), // Peer validation
lzReceiveCall, // Call from executor
],
});
await client.signAndExecuteTransaction({transaction: tx});
Sui-Specific Considerations
Object Abilities
Sui's ability system controls what can be done with types:
| Ability | Meaning | LayerZero Usage |
|---|---|---|
key | Can be stored at top-level | OApp, EndpointV2, AdminCap |
store | Can be stored in other structs | Peer, Channel, UlnConfig |
copy | Can be copied | ChannelKey, MessagingFee |
drop | Can be ignored/discarded | One-time witnesses, config structs |
Call Object Abilities:
public struct Call<Param, Result> {
// Has NO abilities - cannot be dropped or stored
// Must be explicitly destroyed via destroy() or complete_and_destroy()
}
This enforces the hot potato pattern—Call objects must be handled.
Phantom Type Parameters
Sui uses phantom type parameters for type safety without storage:
/// OFT uses phantom T for the coin type
public struct OFT<phantom T> has key {
id: UID,
treasury: OFTTreasury<T>, // T only appears in nested types
// ...
}
/// TreasuryCap also uses phantom T
public struct TreasuryCap<phantom T> has key, store {
id: UID,
total_supply: Supply<T>,
}
The phantom keyword means T is for type safety only—not stored directly.
Table vs Vector
Sui uses Table<K, V> for dynamic key-value storage:
/// Peer mappings by EID
public struct Peer has store {
peers: Table<u32, Bytes32>, // EID → peer address
}
/// vs fixed-size vector
required_dvns: vector<address>, // Known size, stored directly
Trade-offs:
Table: Dynamic size, gas per access, better for sparse datavector: Fixed size, cheaper access, better for dense data
Summary
LayerZero on Sui achieves cross-chain messaging through:
- Object-Based State: Shared objects (
EndpointV2,MessagingChannel,OApp) enable parallel execution - Capability Authorization:
CallCapandAdminCapreplacemsg.senderchecks - Call Pattern:
Call<Param, Result>objects enable dynamic routing withoutdelegatecall - PTB Composition: Atomic multi-step workflows ensure message integrity
- Type Safety: Move's ability system and phantom types provide compile-time guarantees
Key Differences from Other VMs:
- No inheritance (explicit capability validation)
- No dynamic dispatch (Call pattern workaround)
- Object ownership model (shared vs owned vs immutable)
- Coin object model (split/merge instead of balance transfer)
- Table-based storage (not mappings or PDAs)
For implementation guides and code examples, see:
- OApp Implementation - Build custom cross-chain applications
- OFT Implementation - Deploy cross-chain tokens
- OFT SDK - Complete SDK methods and patterns
- Configuration Guide - DVN, executor, and gas configuration
- Technical Overview - Sui fundamentals and architecture