Skip to main content
Version: Endpoint V2

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.

Prerequisites

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):

  1. OApp Initiates Send: User calls the OApp module's send() function, which creates a Call<SendParam, MessagingReceipt> object
  2. Endpoint Processes: The Endpoint increments the outbound nonce, constructs a packet with GUID, and routes to the send library
  3. ULN302 Assigns Jobs: The message library creates child Call objects for the executor and each DVN
  4. Workers Calculate Fees: Executor and DVNs estimate their fees and return FeeRecipient results
  5. Confirmation Chain: Results flow back through confirm functions, aggregating fees and emitting events
  6. 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 Call object (hot potato—must be consumed)
  • The Call has no drop or store abilities
  • PTB must route this Call to the Endpoint module
  • oapp_cap validates 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:

  1. Lazy nonce validation: Ensures all prior messages have been verified
  2. Payload hash verification: Confirms executor provided the correct message
  3. Storage cleanup: Removes hash to prevent double execution
  4. 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 Call came from authorized Endpoint
    • Validate sender matches configured peer
    • Process message and update state
    • No need to call clear() (done by Endpoint before Call creation)

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 TypeOwnershipAccess PatternExample
EndpointV2SharedAnyone reads, admin writes&EndpointV2 or &mut EndpointV2
MessagingChannelSharedAnyone reads, owner writes&mut MessagingChannel
OAppSharedAnyone reads, admin writes&mut OApp
CallCapOwnedMust own to useOwned by OApp module or user
AdminCapOwnedMust own to useOwned by admin address
Call<P, R>NeitherMust be consumed in PTBCreated and destroyed in same TX

Call Pattern vs EVM/Solana

AspectEVMSolanaSui
Cross-Contract CallsdelegatecallCPI (Cross-Program Invocation)Call<Param, Result> objects
Authorizationmsg.senderSigner checks + PDAsCallCap validation
Return ValuesFunction returnsCPI returnsCall.complete() sets result
Call HierarchyCall stack (implicit)CPI depth limit (4)Call parent/child relationships
AtomicityTransaction revertTransaction revertPTB 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

ComponentEVMSolanaSui
Code OrganizationSolidity contractsRust programsMove packages/modules
State StorageContract storage slotsPDA accountsShared/owned objects
Nonce ManagementNested mappingsPDA per pathwayTable in MessagingChannel
Message ChannelContract storagePDA accountsMessagingChannel shared object
Call PatterndelegatecallCPICall<Param, Result> objects
Authorizationmsg.senderSigners + PDA derivationCallCap validation
Fee Paymentmsg.value transferToken account opsCoin object splitting
AtomicityTransaction revertTransaction revertPTB revert

Send Flow Comparison

StepEVMSolanaSui
InitiateOApp.send() internalOApp CPI to EndpointOApp creates Call object
NonceMapping incrementPDA account writeTable field increment
Library CallDirect function callCPI to SendUln302Child Call creation
Worker CallsDirect function callsCPI to each workerChild Call per worker
Fee CollectionTransfer to libraryRecord in libraryCoin splitting
Eventemit PacketSentemit_cpi!(PacketSent)event::emit(PacketSent)

Receive Flow Comparison

StepEVMSolanaSui
Entry PointExecutor calls Endpoint.lzReceiveExecutor invokes with all accountsExecutor calls execute_lz_receive
Clear PayloadEndpoint clears before calling OAppOApp CPIs back to Endpoint.clearEndpoint clears before creating Call
OApp Invocationdelegatecall to OApp.lzReceiveInstruction with account listCall object to OApp module
ValidationModifier checksAccount constraints + CPI authCall validation + peer check
ProcessingOverride _lzReceiveImplement lz_receive instructionImplement lz_receive function

Event Monitoring

Events Emitted During Send

  1. ExecutorFeePaidEvent (from ULN):
public struct ExecutorFeePaidEvent has copy, drop {
guid: Bytes32,
executor: address,
fee: FeeRecipient,
}
  1. DVNFeePaidEvent (from ULN):
public struct DVNFeePaidEvent has copy, drop {
guid: Bytes32,
dvns: vector<address>,
fees: vector<FeeRecipient>,
}
  1. 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
}
  1. 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

  1. PayloadVerifiedEvent (per DVN):
public struct PayloadVerifiedEvent has copy, drop {
dvn: address,
header: vector<u8>,
confirmations: u64,
proof_hash: Bytes32,
}
  1. 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

  1. PacketDeliveredEvent:
public struct PacketDeliveredEvent has copy, drop {
src_eid: u32,
sender: Bytes32,
receiver: address,
nonce: u64,
}
  1. 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:

AbilityMeaningLayerZero Usage
keyCan be stored at top-levelOApp, EndpointV2, AdminCap
storeCan be stored in other structsPeer, Channel, UlnConfig
copyCan be copiedChannelKey, MessagingFee
dropCan be ignored/discardedOne-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 data
  • vector: Fixed size, cheaper access, better for dense data

Summary

LayerZero on Sui achieves cross-chain messaging through:

  1. Object-Based State: Shared objects (EndpointV2, MessagingChannel, OApp) enable parallel execution
  2. Capability Authorization: CallCap and AdminCap replace msg.sender checks
  3. Call Pattern: Call<Param, Result> objects enable dynamic routing without delegatecall
  4. PTB Composition: Atomic multi-step workflows ensure message integrity
  5. 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: