Skip to main content
Version: Endpoint V2

LayerZero V2 Starknet Protocol Overview

This page provides a deep technical dive into the LayerZero V2 protocol implementation on Starknet, documenting the complete message lifecycle with contract-level code samples and function signatures.

What you'll find:

  • Complete send workflow (quote, approve, send, endpoint processing)
  • DVN verification process and verification status checks
  • Executor delivery and OApp receive handling
  • Recovery operations (skip, clear, nilify, burn)
  • Security considerations for payload hashes and reentrancy

Target audience: Developers who understand Starknet basics and want to deeply understand the protocol implementation.

Prerequisites

Before reading this page, familiarize yourself with Starknet fundamentals in Technical Overview. For SDK usage and practical implementation, see OApp or OFT guides.


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, verification checks, and execution readiness
  • Receive Workflow: Executor delivery, payload clearing, and OApp processing

Send Overview

When an OApp initiates a cross-chain message, the following high-level steps occur on the source chain.

Message Lifecycle Overview

The LayerZero protocol enables secure cross-chain messaging through a three-phase process:

Loading diagram...

Core Data Structures

Packet

The Packet structure represents a cross-chain message:

#[derive(Clone, Drop, Serde)]
pub struct Packet {
pub nonce: u64, // Sequential message number
pub src_eid: u32, // Source Endpoint ID
pub sender: ContractAddress, // Source OApp address
pub dst_eid: u32, // Destination Endpoint ID
pub receiver: Bytes32, // Destination OApp (bytes32 for cross-VM compatibility)
pub guid: Bytes32, // Globally Unique Identifier
pub message: ByteArray, // Application payload
}

Origin

The Origin structure identifies the source of an incoming message:

#[derive(Clone, Drop, Serde, Debug, PartialEq, Default)]
pub struct Origin {
pub src_eid: u32, // Source chain Endpoint ID
pub sender: Bytes32, // Source OApp address (bytes32)
pub nonce: u64, // Message nonce for ordering
}

MessagingParams

Parameters for sending a message:

pub struct MessagingParams {
pub dst_eid: u32, // Destination Endpoint ID
pub receiver: Bytes32, // Recipient OApp address
pub message: ByteArray, // Application payload
pub options: ByteArray, // Execution options (gas, etc.)
pub pay_in_lz_token: bool, // Pay fees in ZRO token
}

MessagingFee

Fee structure returned by quote operations:

pub struct MessagingFee {
pub native_fee: u256, // Fee in native token (STRK/ETH)
pub lz_token_fee: u256, // Fee in ZRO token (if applicable)
}

Send Workflow

When an OApp initiates a cross-chain message, the following steps occur:

Step 1: Quote the Fee

Before sending, get a fee estimate:

// In your OApp or client code
fn quote_send(
self: @ContractState,
dst_eid: u32,
message: ByteArray,
options: ByteArray,
) -> MessagingFee {
let params = MessagingParams {
dst_eid,
receiver: self.peers.read(dst_eid),
message,
options,
pay_in_lz_token: false,
};

let endpoint = IEndpointV2Dispatcher {
contract_address: self.endpoint.read()
};

endpoint.quote(params, get_contract_address())
}

Step 2: Approve Fees

The caller must approve the Endpoint to spend their tokens:

// Approve native token for fee payment
let native_token = IERC20Dispatcher { contract_address: native_token_address };
native_token.approve(endpoint_address, fee.native_fee);

Step 3: Send the Message

The OApp calls the Endpoint's send function:

fn send(
ref self: ContractState,
dst_eid: u32,
message: ByteArray,
options: ByteArray,
fee: MessagingFee,
refund_address: ContractAddress,
) -> MessageReceipt {
let params = MessagingParams {
dst_eid,
receiver: self.peers.read(dst_eid),
message,
options,
pay_in_lz_token: false,
};

let endpoint = IEndpointV2Dispatcher {
contract_address: self.endpoint.read()
};

endpoint.send(params, refund_address)
}

Step 4: Endpoint Processing

The Endpoint performs the following:

  1. Creates the Packet with a unique GUID and incremented nonce
  2. Looks up the Send Library for this OApp/destination pair
  3. Routes to Message Library (ULN302) for worker fee calculation
  4. Pays Workers (DVNs, Executor) via ERC20 transfers
  5. Emits PacketSent event with encoded packet and options
// Internal Endpoint logic (simplified)
fn send(ref self: ContractState, params: MessagingParams, refund_address: ContractAddress) -> MessageReceipt {
let sender = get_caller_address();

// Create packet with new nonce
let nonce = self.outbound_nonce(sender, params.dst_eid, params.receiver) + 1;
let packet = Packet {
nonce,
src_eid: self.eid.read(),
sender,
dst_eid: params.dst_eid,
receiver: params.receiver,
guid: GUID::generate(nonce, src_eid, sender, dst_eid, receiver),
message: params.message,
};

// Send through message library and pay workers
let result = message_lib.send(packet, params.options, params.pay_in_lz_token);
self._pay_workers(sender, result.receipt, refund_address, params.pay_in_lz_token);

// Emit event for off-chain listeners
self.emit(PacketSent {
encoded_packet: result.encoded_packet,
options: params.options,
send_library
});

result.message_receipt
}

Events Emitted During Send

EventDescription
PacketSentContains encoded packet, options, and send library address

Verification Workflow

After a PacketSent event is emitted, DVNs verify the message and the destination Endpoint records verification data.

DVN Verification Process

Step 1: DVN Monitors Source Chain

DVNs monitor the source chain for PacketSent events and extract the packet data.

Step 2: DVN Submits Verification

Once a DVN has verified the packet (e.g., confirmed finality), it submits the verification to the receive library. The receive library then calls verify on the destination Endpoint.

// Called by receive library on destination chain after DVN quorum
fn verify(
ref self: ContractState,
origin: Origin,
receiver: ContractAddress,
payload_hash: Bytes32,
) {
// Verify caller is a valid receive library
self._assert_only_receive_library(receiver, origin.src_eid);

// Store the payload hash
self.inbound_payload_hash.write(
(receiver, origin.src_eid, origin.sender, origin.nonce),
payload_hash
);

self.emit(PacketVerified { origin, receiver, payload_hash });
}

Step 3: Check Verification Status

The Executor (or anyone) can check if a message is ready for execution:

fn executable(self: @ContractState, origin: Origin, receiver: ContractAddress) -> ExecutionState {
let payload_hash = self.inbound_payload_hash(receiver, origin.src_eid, origin.sender, origin.nonce);

if payload_hash == EMPTY_PAYLOAD_HASH && nonce <= lazy_inbound_nonce {
return ExecutionState::Executed; // Already executed
}

if payload_hash != NIL_PAYLOAD_HASH && nonce <= inbound_nonce {
return ExecutionState::Executable; // Ready to execute
}

if payload_hash != EMPTY_PAYLOAD_HASH && payload_hash != NIL_PAYLOAD_HASH {
return ExecutionState::VerifiedButNotExecutable; // Verified but blocked
}

ExecutionState::NotExecutable
}

Events Emitted During Verification

EventDescription
PacketVerifiedContains origin, receiver, and payload hash

Receive Workflow

Once verified, the Executor delivers the message to the destination OApp.

Executor Delivery

Step 1: Executor Calls lz_receive

// Called by Executor on destination chain
fn lz_receive(
ref self: ContractState,
origin: Origin,
receiver: ContractAddress,
guid: Bytes32,
message: ByteArray,
extra_data: ByteArray,
value: u256, // Native token value to forward
) {
// Clear payload hash first (prevents reentrancy)
let payload = self._create_payload(guid, @message);
self._clear_payload(receiver, @origin, @payload);

// Transfer value to receiver (if any)
if value > 0 {
native_token.transfer_from(executor, receiver, value);
}

// Call receiver's lz_receive
let receiver_dispatcher = ILayerZeroReceiverDispatcher { contract_address: receiver };
receiver_dispatcher.lz_receive(origin, guid, message, executor, extra_data, value);

self.emit(PacketDelivered { origin, receiver });
}

Step 2: OApp Handles the Message

Your OApp implements the ILayerZeroReceiver interface:

impl OAppHooks of OAppCoreComponent::OAppHooks<ContractState> {
fn _lz_receive(
ref self: OAppCoreComponent::ComponentState<ContractState>,
origin: Origin,
guid: Bytes32,
message: ByteArray,
executor: ContractAddress,
extra_data: ByteArray,
value: u256,
) {
// Decode and process the message
// The OApp has access to:
// - origin.src_eid: source chain
// - origin.sender: source OApp (bytes32)
// - message: application payload
// - value: native tokens forwarded

// Your custom logic here
}
}

Events Emitted During Receive

EventDescription
PacketDeliveredConfirms successful delivery to receiver
LzReceiveAlertEmitted if lz_receive execution fails

Recovery Operations

LayerZero provides mechanisms for handling stuck or problematic messages:

Skip

Skip an unverified message (before DVN verification):

fn skip(ref self: ContractState, oapp: ContractAddress, src_eid: u32, sender: Bytes32, nonce: u64) {
// Only callable by OApp owner/delegate
// Marks nonce as processed without verification
}

Use Case: Skip a message that will never be verified (e.g., source chain reorg).

Clear

Clear a verified but unexecuted message:

fn clear(
ref self: ContractState,
origin: Origin,
receiver: ContractAddress,
guid: Bytes32,
message: ByteArray,
) {
self._assert_authorized(receiver);

let payload = self._create_payload(guid, @message);
self._clear_payload(receiver, @origin, @payload);

self.emit(PacketDelivered { origin, receiver });
}

Use Case: Clear a message that's blocking subsequent messages due to ordering.

Nilify

Reset a verification to allow re-verification:

fn nilify(ref self: ContractState, oapp: ContractAddress, src_eid: u32, sender: Bytes32, nonce: u64, payload_hash: Bytes32) {
// Sets payload hash to NIL_PAYLOAD_HASH
// Message must be re-verified before execution
}

Use Case: Dispute a verification or handle DVN misbehavior.

Burn

Permanently block a message:

fn burn(ref self: ContractState, oapp: ContractAddress, src_eid: u32, sender: Bytes32, nonce: u64, payload_hash: Bytes32) {
// Permanently marks message as non-executable
// Cannot be reversed
}

Use Case: Permanently reject a malicious or invalid message.

OperationReversibleWhen to Use
skipYesMessage won't be verified
clearNoUnblock message ordering
nilifyYesDispute verification
burnNoPermanently reject message

Security Considerations

Payload Hash Verification

The Endpoint stores only the hash of the payload, not the full message. This:

  • Saves storage costs
  • Prevents spam attacks
  • Requires Executor to provide correct message data

Reentrancy Protection

The Endpoint uses OpenZeppelin's ReentrancyGuard component:

fn send(ref self: ContractState, params: MessagingParams, refund_address: ContractAddress) -> MessageReceipt {
self.reentrancy_guard.start(); // Lock
// ... send logic ...
self.reentrancy_guard.end(); // Unlock
message_receipt
}

Clear Before Execute

The lz_receive function clears the payload hash before calling the receiver's handler:

// Clear first (prevents reentrancy attacks)
self._clear_payload(receiver, @origin, @payload);

// Then execute (safe even if receiver calls back)
receiver_dispatcher.lz_receive(...);

This "clear-then-execute" pattern prevents reentrancy attacks where a malicious receiver could attempt to re-execute the same message.


Next Steps