LayerZero Sui OApp
The OApp Standard provides developers with a generic message passing interface to send and receive arbitrary pieces of data between contracts existing on different blockchain networks.
How the data is interpreted and what actions it triggers depend on the specific OApp implementation.
What is an OApp on Sui?
An OApp on Sui is a Move package that integrates with the LayerZero protocol to enable cross-chain messaging. Unlike EVM OApps that inherit base contracts, Sui OApps use shared objects and explicit function calls within Programmable Transaction Blocks (PTBs).
Differences from EVM OApps
| Aspect | EVM | Sui |
|---|---|---|
| Code Organization | Solidity contracts | Move packages containing modules |
| Integration Method | Inherit from OApp base contract | Use oapp package and Call pattern |
| Receive Flow | Endpoint calls lzReceive via delegatecall | Endpoint creates Call object for OApp module |
| Validation | Implicit via inheritance | Explicit via CallCap validation |
| State Model | Contract storage slots | Shared objects with struct fields |
| OApp Identity | Contract address | Shared OApp object ID |
| Authorization | msg.sender and modifiers | Capability objects (CallCap, AdminCap) |
Installation
Prerequisites
- Sui CLI installed (version 1.54.1 or later)
- Basic understanding of Move programming
- Familiarity with Sui's object model
Create a New Project
Create a new Sui Move package:
mkdir my-oapp
cd my-oapp
sui move new my_oapp
This creates:
my_oapp/
├── Move.toml
├── sources/
└── tests/
Configure Move.toml
Git dependencies for LayerZero packages currently do not work due to missing Move.toml manifests in subdirectories. Use local dependencies instead.
Clone LayerZero Repository:
cd ..
git clone https://github.com/LayerZero-Labs/LayerZero-v2.git
cd my-oapp
Update Move.toml with local paths:
[package]
name = "my_oapp"
version = "0.0.1"
edition = "2024.beta"
[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "mainnet" }
# LayerZero packages - use local paths
OApp = { local = "../LayerZero-v2/packages/layerzero-v2/sui/contracts/oapps/oapp" }
EndpointV2 = { local = "../LayerZero-v2/packages/layerzero-v2/sui/contracts/endpoint-v2" }
Call = { local = "../LayerZero-v2/packages/layerzero-v2/sui/contracts/dynamic-call/call" }
Utils = { local = "../LayerZero-v2/packages/layerzero-v2/sui/contracts/utils" }
[addresses]
my_oapp = "0x0"
Alternative: Use Published Package Addresses If LayerZero packages are published on-chain, you can reference them by address:
[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "mainnet" }
# Reference by published address (check deployments page for current addresses)
OApp = { address = "0xfdc28afc0110cb2edb94e3e57f2b1ce69b5a99c503b06d15e51cfa212de56e24" }
# ... other packages
[addresses]
my_oapp = "0x0"
See Deployed Contracts for current mainnet package addresses.
Working Example: OFT Implementation
The Omnichain Fungible Token (OFT) is a complete, production-ready implementation of an OApp that demonstrates all core messaging patterns. OFTs extend OApp functionality to enable cross-chain token transfers.
To see a working OApp in action, review the OFT Overview which shows:
- Complete initialization and deployment workflow
- Message encoding/decoding patterns
- Integration with Sui's object model and coin framework
- Production deployment examples with TypeScript SDK
Source Code References:
How OApp Messaging Works
Understanding how OApps work on Sui requires understanding several key concepts that differ from EVM implementations.
Initialization: Creating Your OApp
When you initialize an OApp on Sui, the oapp::new() function creates three critical components using the one-time witness (OTW) pattern:
- OApp Shared Object: Contains configuration state (peers, enforced options) that anyone can read but only admins can modify
- CallCap (owned): Proves ownership of the OApp and is required for all messaging operations
- AdminCap (owned): Grants authority for administrative operations like setting peers and configuring options
Key Point: The OApp object is automatically made shared (via transfer::share_object()), while the capabilities are transferred to the deployer. This separation allows secure access control using Sui's capability-based authorization system rather than address-based checks.
Registration: Connecting to the Endpoint
After initialization, your OApp must register with the LayerZero Endpoint by calling endpoint_v2::register_oapp(). This creates a dedicated MessagingChannel shared object that stores your OApp's message state and nonce tracking for each pathway.
What the registry stores: The Endpoint registry maps your package ID (not object ID) to your MessagingChannel. This is critical because Sui OApps use Package CallCaps, which identify by package address.
Peer Configuration: Establishing Trust
Peers are trusted OApp addresses on remote chains that are authorized to send messages to your OApp. You configure peers by calling oapp::set_peer() with the AdminCap:
Critical: Package ID vs Object ID
On Sui, peers must be configured using package IDs, not object IDs:
- Your Sui OApp: Use the package ID published address as the peer on remote chains
- Remote OApp peers: Use their contract/package addresses (EVM contract address, Solana program ID, etc.)
This is because LayerZero's registry and verification systems key by package address for Sui OApps. Using an object ID will cause the error: oapp_registry::get_messaging_channel abort code: 1.
Sending Messages: The Call Pattern
When your OApp sends a message, it creates a Call object using oapp::lz_send(). This Call object is a "hot potato" - it has no drop or store abilities, meaning it must be consumed before the transaction ends.
The Send Flow:
- Your OApp calls
lz_send()→ createsCall<SendParam, MessagingReceipt> - Call is routed through a PTB to: Endpoint → ULN302 → DVNs & Executor
- Each component processes and returns the Call
- Your OApp calls
confirm_lz_send()to extract the receipt and finalize
Why the Call pattern? Sui Move lacks dynamic dispatch (like EVM's delegatecall). The Call pattern achieves similar routing functionality while maintaining type safety and preventing reentrancy attacks through Move's ability system.
How OFT Implements Custom Send Logic:
// From oft.move - shows how custom business logic wraps OApp messaging
public fun send<T>(
self: &mut OFT<T>,
oapp: &mut OApp,
sender: &OFTSender,
send_param: &SendParam,
coin_provided: &mut Coin<T>,
native_coin_fee: Coin<SUI>,
zro_coin_fee: Option<Coin<ZRO>>,
refund_address: Option<address>,
clock: &Clock,
ctx: &mut TxContext,
): (Call<EndpointSendParam, MessagingReceipt>, OFTSendContext) {
// 1. Custom business logic: Validate state
self.assert_upgrade_version();
self.pausable.assert_not_paused();
// 2. Custom business logic: Debit tokens (burn or escrow)
let (amount_sent_ld, amount_received_ld) = self.debit(
coin_provided,
send_param.dst_eid(),
send_param.amount_ld(),
send_param.min_amount_ld(),
ctx,
);
// 3. Custom business logic: Apply rate limits
self.inbound_rate_limiter.release_rate_limit_capacity(/*...*/);
self.outbound_rate_limiter.try_consume_rate_limit_capacity(/*...*/);
// 4. Custom business logic: Build OFT-specific message (recipient + amount)
let (message, options) = self.build_msg_and_options(/*...*/);
// 5. Call base OApp send functionality
let ep_call = oapp.lz_send(
&self.oft_cap, // Prove OFT owns this OApp
send_param.dst_eid(), // Destination chain
message, // Encoded OFT message
options, // Execution options
native_coin_fee, // Fee payment
zro_coin_fee,
refund_address,
ctx,
);
// 6. Return Call and context for confirmation
(ep_call, send_context)
}
This pattern shows how your custom OApp would:
- Add application-specific validation and state changes
- Encode your business logic into the message payload
- Call the base
oapp::lz_send()function - Return the Call for PTB routing
Sequential vs Parallel Sends:
lz_send()+confirm_lz_send(): Enforces sequential execution (one send at a time)lz_send_and_refund(): Allows parallel sends in the same PTB (messages can be reordered)
Receiving Messages: Validation and Processing
When a message arrives on Sui, the Executor calls endpoint_v2::lz_receive(), which creates a Call<LzReceiveParam, Void> object targeting your OApp. Your OApp's lz_receive() function must:
- Validate the CallCap: Ensure the Call belongs to this OApp
- Check the Endpoint: Verify the Call came from the authorized LayerZero Endpoint
- Verify the Peer: Confirm the sender matches your configured peer for that source chain
- Process the message: Decode and execute your custom business logic
How OFT Implements Custom Receive Logic:
// From oft.move - shows validation + custom business logic
public fun lz_receive<T>(
self: &mut OFT<T>,
oapp: &OApp,
call: Call<LzReceiveParam, Void>,
clock: &Clock,
ctx: &mut TxContext,
) {
// 1. Custom business logic: Pre-receive validation
self.assert_upgrade_version();
self.pausable.assert_not_paused();
// 2. Base OApp validation (CallCap, Endpoint, Peer)
// Returns validated LzReceiveParam
let param = oapp.lz_receive(&self.oft_cap, call);
// 3. Custom business logic: Decode OFT message
let oft_msg = oft_msg_codec::decode(param.message());
let recipient = oft_msg.send_to();
let amount_received_sd = oft_msg.amount_sd();
// 4. Custom business logic: Convert from shared to local decimals
let amount_received_ld = self.sd_to_ld(amount_received_sd);
// 5. Custom business logic: Credit tokens (mint or release from escrow)
let coin_credited = self.credit(amount_received_ld, ctx);
// 6. Custom business logic: Apply rate limits
self.inbound_rate_limiter.try_consume_rate_limit_capacity(
param.src_eid(),
amount_received_ld,
clock,
);
// 7. Custom business logic: Transfer tokens to recipient
transfer::public_transfer(coin_credited, recipient);
// 8. Emit event for tracking
event::emit(OFTReceivedEvent { /* ... */ });
}
This pattern shows how your custom OApp would:
- Delegate security validation to
oapp.lz_receive()(returns validated params) - Decode the message payload to extract your application data
- Execute your custom business logic (state updates, token transfers, etc.)
- Handle any post-processing (events, cleanup)
How This Differs from EVM
| Aspect | EVM | Sui |
|---|---|---|
| Message Routing | Endpoint calls lzReceive() via delegatecall | Endpoint creates Call object, PTB routes to OApp |
| Validation | Implicit via onlyEndpoint modifier | Explicit via CallCap and Call pattern |
| Execution Flow | Single transaction with nested calls | PTB chains multiple function calls atomically |
| Authorization | Address-based (msg.sender) | Capability-based (own the CallCap) |
| Composability | Vertical (nested calls in one tx) | Horizontal (chained calls in PTB) |
Message Encoding and Business Logic
Your OApp is responsible for encoding/decoding message payloads. LayerZero transports raw bytes - how you structure them depends on your application.
Key Principles:
- Use consistent byte order (big-endian recommended for cross-VM compatibility with EVM)
- Document your message format clearly
- Consider padding for fixed-width fields
- Test encoding/decoding on both source and destination chains
Example from OFT: The oft_msg_codec.move shows a production codec that encodes recipient address, amount in shared decimals, and optional compose parameters.
Required Components
Every OApp on Sui requires four key components:
1. OApp Shared Object
Contains configuration state including:
- Peer mappings: Maps endpoint IDs to trusted remote OApp addresses
- Enforced options: Minimum gas and execution parameters for each destination
- Embedded CallCap: Used internally for authentication
- Admin tracking: Reference to the AdminCap owner address
The OApp object is created as a shared object, making it publicly accessible for reading configuration while restricting modifications to capability holders.
2. Capability Objects
CallCap (owned): Required for all messaging operations (quote, lz_send). This proves ownership of the OApp and is validated on every call. Typically stored within your application's module or transferred to a specific address.
AdminCap (owned): Grants authority for configuration operations like setting peers, configuring DVNs, and updating enforced options. Transferable to enable admin rotation.
3. MessagingChannel
Created during Endpoint registration, this shared object stores state for your OApp's communication channel:
- Message nonces for ordering
- Payload hashes for verification
- Channel initialization state per destination EID
The registry maps your package ID → MessagingChannel, which is why peers must use package IDs.
4. Message Codec
A module in your package that defines how to encode/decode your business logic into bytes that LayerZero transports cross-chain. This is application-specific - OFT uses oft_msg_codec, your custom OApp would define its own format.
Message Flow
Send Flow
┌─────────────┐
│ OApp │ 1. User calls send()
│ (Your App) │
└──────┬──────┘
│ 2. Create SendParam
▼
┌─────────────┐
│ Endpoint │ 3. Throws Hot Potato
└──────┬──────┘
│ 4. PTB routes to ULN
▼
┌─────────────┐
│ ULN302 │ 5. Assign jobs to workers
└──────┬──────┘
│ 6. Throws Hot Potatoes
▼
┌─────────────────┐
│ DVNs + Executor │ 7. Process and return results
└─────────────────┘
Receive Flow
┌─────────────┐
│ Executor │ 1. Calls lzReceive
└──────┬──────┘
│ 2. Throws Hot Potato
▼
┌─────────────┐
│ Endpoint │ 3. Routes to OApp
└──────┬──────┘
│ 4. Throws Hot Potato
▼
┌─────────────┐
│ OApp │ 5. Process message
│ (Your App) │ 6. Update state
└─────────────┘
Core Methods
These are the primary functions your OApp will call to send messages and receive them from other chains.
quote()
Estimates the fee required to send a cross-chain message without actually sending it. Returns a Call<QuoteParam, MessagingFee> that must be routed through the Endpoint in a PTB, then confirmed with confirm_quote() to extract the fee amount.
When to use: Before sending to determine how much SUI to include in the transaction, or to display estimated costs to users.
lz_send()
Sends a cross-chain message to a destination chain. Creates a Call<SendParam, MessagingReceipt> that routes through Endpoint → ULN → DVNs/Executor, then must be confirmed with confirm_lz_send() to extract the receipt.
Key parameters:
dst_eid: Destination chain endpoint IDmessage: Your encoded payload (raw bytes)options: Execution parameters (gas limits, msg.value)native_token_fee: SUI payment for cross-chain deliveryrefund_address: Where to send excess fees
Sequential execution: Uses internal state tracking (sending_call) to enforce one send at a time. Must call confirm_lz_send() before initiating another send.
lz_send_and_refund()
Alternative send method that allows parallel message sending within the same PTB. Unlike lz_send(), this doesn't track state and doesn't require confirmation, making it suitable for batch operations. Requires a refund address (cannot be optional).
When to use: When sending multiple messages in one transaction and order doesn't matter.
lz_receive()
Processes incoming messages delivered by the Executor. This function is called with a Call<LzReceiveParam, Void> created by the Endpoint. It performs three critical validations:
- CallCap validation: Ensures the Call belongs to this OApp
- Endpoint check: Verifies the Call originated from the authorized LayerZero Endpoint
- Peer verification: Confirms the message sender matches the configured peer for the source chain
After validation, it returns LzReceiveParam containing the decoded message data for your business logic to process.
Your implementation: You'll wrap oapp::lz_receive() in your own function that adds application-specific processing (see OFT's implementation for reference).
Best Practices
Always Validate CallCap
Every function that accepts a CallCap must call self.assert_oapp_cap(oapp_cap) to ensure it belongs to this OApp. This prevents unauthorized calls and ensures type safety.
Verify Message Senders in lz_receive
Always validate that incoming messages come from configured peers. The oapp::lz_receive() base function handles this validation, but custom receive logic must preserve these checks.
Use One-Time Witness for Initialization
Use the one-time witness pattern in your module's init() function to create the OApp. This guarantees initialization runs exactly once.
Configure Security Before Setting Peers
Set your message libraries and DVN configuration before calling set_peer(). Setting a peer opens the pathway for messaging, so security should be configured first.
Confirm All Call Objects
Every Call object returned by quote(), lz_send(), or similar functions must be consumed in a PTB (routed through protocol components) and confirmed to extract results. Unused Call objects will cause transaction failures due to their lack of drop ability.
Configuration
Before your OApp can send messages, you must configure:
- Initialize Channel: Create channel state for remote EID
- Set Peer: Define the peer OApp address on the remote chain
- Configure DVNs: Set which DVNs verify your messages (optional, defaults used if not set)
- Configure Executor: Set who executes messages on destination (optional, defaults used if not set)
See the Configuration Guide for details.
Security Considerations
Critical Validations
- Always validate OApp object in every function
- Verify message sender matches configured peer
- Check Endpoint address matches stored value
- Validate nonce sequence to prevent replay attacks
Common Pitfalls
- Forgetting to call
assert_oapp - Not validating message sender in
lzReceive - Incorrect peer address configuration
- Missing channel initialization
Next Steps
- OFT Implementation - Token standard built on OApp
- OFT SDK - TypeScript SDK methods and patterns
- Configuration Guide - DVN and executor setup
- Technical Overview - Sui fundamentals and Call pattern
- Protocol Overview - Complete message workflows
- Troubleshooting - Common issues and solutions