Skip to main content
Version: Endpoint V2

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

AspectEVMSui
Code OrganizationSolidity contractsMove packages containing modules
Integration MethodInherit from OApp base contractUse oapp package and Call pattern
Receive FlowEndpoint calls lzReceive via delegatecallEndpoint creates Call object for OApp module
ValidationImplicit via inheritanceExplicit via CallCap validation
State ModelContract storage slotsShared objects with struct fields
OApp IdentityContract addressShared OApp object ID
Authorizationmsg.sender and modifiersCapability objects (CallCap, AdminCap)

Installation

Prerequisites

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 Not Supported

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"
info

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:

  • oapp.move - Base OApp implementation
  • oft.move - OFT extending OApp with token logic

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:

  1. OApp Shared Object: Contains configuration state (peers, enforced options) that anyone can read but only admins can modify
  2. CallCap (owned): Proves ownership of the OApp and is required for all messaging operations
  3. 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:

  1. Your OApp calls lz_send() → creates Call<SendParam, MessagingReceipt>
  2. Call is routed through a PTB to: Endpoint → ULN302 → DVNs & Executor
  3. Each component processes and returns the Call
  4. 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:

  1. Add application-specific validation and state changes
  2. Encode your business logic into the message payload
  3. Call the base oapp::lz_send() function
  4. 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:

  1. Validate the CallCap: Ensure the Call belongs to this OApp
  2. Check the Endpoint: Verify the Call came from the authorized LayerZero Endpoint
  3. Verify the Peer: Confirm the sender matches your configured peer for that source chain
  4. 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:

  1. Delegate security validation to oapp.lz_receive() (returns validated params)
  2. Decode the message payload to extract your application data
  3. Execute your custom business logic (state updates, token transfers, etc.)
  4. Handle any post-processing (events, cleanup)

How This Differs from EVM

AspectEVMSui
Message RoutingEndpoint calls lzReceive() via delegatecallEndpoint creates Call object, PTB routes to OApp
ValidationImplicit via onlyEndpoint modifierExplicit via CallCap and Call pattern
Execution FlowSingle transaction with nested callsPTB chains multiple function calls atomically
AuthorizationAddress-based (msg.sender)Capability-based (own the CallCap)
ComposabilityVertical (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 ID
  • message: Your encoded payload (raw bytes)
  • options: Execution parameters (gas limits, msg.value)
  • native_token_fee: SUI payment for cross-chain delivery
  • refund_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:

  1. CallCap validation: Ensures the Call belongs to this OApp
  2. Endpoint check: Verifies the Call originated from the authorized LayerZero Endpoint
  3. 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:

  1. Initialize Channel: Create channel state for remote EID
  2. Set Peer: Define the peer OApp address on the remote chain
  3. Configure DVNs: Set which DVNs verify your messages (optional, defaults used if not set)
  4. 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