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.
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:
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:
- Creates the Packet with a unique GUID and incremented nonce
- Looks up the Send Library for this OApp/destination pair
- Routes to Message Library (ULN302) for worker fee calculation
- Pays Workers (DVNs, Executor) via ERC20 transfers
- 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
| Event | Description |
|---|---|
PacketSent | Contains 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
| Event | Description |
|---|---|
PacketVerified | Contains 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
| Event | Description |
|---|---|
PacketDelivered | Confirms successful delivery to receiver |
LzReceiveAlert | Emitted 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.
| Operation | Reversible | When to Use |
|---|---|---|
skip | Yes | Message won't be verified |
clear | No | Unblock message ordering |
nilify | Yes | Dispute verification |
burn | No | Permanently 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
- Technical Overview - Starknet architecture details
- OApp Overview - Building custom OApps
- OFT Overview - Token transfer patterns
- Configuration Guide - DVN and Executor setup