What is an OApp on Sui?
An OApp on Sui is a Move package that integrates with the LayerZero protocol to enable crosschain 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:Configure Move.toml
Clone LayerZero Repository:Move.toml with local paths:
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 crosschain 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
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, theoapp::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
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 callingendpoint_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 callingoapp::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.)
oapp_registry::get_messaging_channel abort code: 1.
Sending Messages: The Call Pattern
When your OApp sends a message, it creates a Call object usingoapp::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
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:
- 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
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 callsendpoint_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
- 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
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
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
4. Message Codec
A module in your package that defines how to encode/decode your business logic into bytes that LayerZero transports crosschain. This is application-specific - OFT usesoft_msg_codec, your custom OApp would define its own format.
Message Flow
Send Flow
Receive Flow
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 crosschain message without actually sending it. Returns aCall<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 crosschain message to a destination chain. Creates aCall<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 crosschain deliveryrefund_address: Where to send excess fees
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. Unlikelz_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 aCall<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
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 aCallCap 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. Theoapp::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’sinit() function to create the OApp. This guarantees initialization runs exactly once.
Configure Security Before Setting Peers
Set your message libraries and DVN configuration before callingset_peer(). Setting a peer opens the pathway for messaging, so security should be configured first.
Confirm All Call Objects
EveryCall 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)
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