Getting Started with LayerZero V2 on Sui
Any data, whether it's a fungible token transfer, an NFT, or some other smart contract input can be encoded on-chain as bytes and delivered to a destination chain to trigger some action using LayerZero.
Because of this, any blockchain that broadly supports state propagation and events can be connected to LayerZero, including Sui.
If you're new to LayerZero, we recommend reviewing "What is LayerZero?" before continuing.
LayerZero provides Sui Move Packages that can communicate with the equivalent Solidity Contract Libraries and Solana Programs deployed on other chains.
These packages, like their Solidity and Rust counterparts, simplify calling the LayerZero Endpoint, provide message handling, interfaces for protocol configurations, and other utilities for interoperability:
-
Omnichain Fungible Token (OFT): extends OApp with functionality for handling omnichain token transfers using Sui's coin framework.
-
Omnichain Application (OApp): the base package utilities for omnichain messaging and configuration.
Each of these package standards implements common functions for sending and receiving omnichain messages.
Differences from the Ethereum Virtual Machine
The full differences between Solidity/EVM and Sui/Move are significant. For comprehensive guides, see:
Skip this section if you already feel comfortable working with the Sui blockchain and its object model.
Object Model vs Account Model
The most fundamental difference is how state is organized:
EVM (Account Model):
Account {
address: 0x123...
balance: 100 ETH
storage: {
slot_0: value_0,
slot_1: value_1,
...
}
code: bytecode
}
All state lives in storage slots within the account. Functions modify these slots.
Sui (Object Model):
Object {
id: UID (globally unique)
owner: Address | Shared | Immutable
type: Module::StructName
fields: {
field_1: value_1,
field_2: value_2,
...
}
}
State lives in individual objects. Functions take objects as parameters and modify them.
Writing Smart Contracts on Sui
To create a new ERC20 token on an EVM-compatible blockchain, a developer inherits and redeploys the ERC20 contract:
// EVM: Inherit and deploy
contract MyToken is ERC20 {
constructor() ERC20("MyToken", "MTK") {}
}
Sui is different. Instead of inheritance, Sui uses:
- Packages: Published Move code (immutable)
- Objects: State containers with unique IDs
- Capabilities: Authorization objects
Rather than deploying a new contract, you publish a package once, then create object instances.
One-Time Witness Pattern: Sui uses the one-time witness (OTW) pattern to prove code runs exactly once during package initialization:
/// Sui: One-time witness pattern
/// Struct name must match module name in ALL_CAPS
public struct MY_TOKEN has drop {} // Only `drop` ability
/// Called once when package is published
fun init(otw: MY_TOKEN, ctx: &mut TxContext) {
// Create coin with metadata
// The `otw` parameter can only be created once by the runtime
let (treasury_cap, coin_metadata) = coin::create_currency(
otw, // Proves this is the first/only call
9, // Decimals
b"MTK", // Symbol
b"MyToken", // Name
b"My token", // Description
option::none(),
ctx
);
// CoinMetadata automatically frozen (immutable)
// TreasuryCap transferred to deployer (can mint/burn)
transfer::public_transfer(treasury_cap, ctx.sender());
}
Key Differences:
- No redeploy: Package is published once, objects created many times
- No inheritance: Use composition and capabilities instead
- Object ownership: State has explicit ownership (address, shared, immutable)
- Type safety: Move's type system prevents many runtime errors
Object Ownership Types
Sui's ownership model determines who can access and modify objects. Understanding these types is essential for building LayerZero applications:
| Ownership | Access | Example | LayerZero Usage |
|---|---|---|---|
| Owned | Only owner can use | AdminCap, CallCap | Authorization objects |
| Shared | Anyone can reference | OApp, EndpointV2 | Protocol state objects |
| Immutable | Anyone can read | CoinMetadata<T> | Published packages |
OApp on Sui:
/// Shared OApp configuration object
/// Contains peer configuration and enforced options for cross-chain messaging
/// The delegate (authorized by the OApp owner) can update these settings
public struct OApp has key {
id: UID,
oapp_cap: CallCap, // Embedded capability for authentication
admin_cap: address, // Reference to owned AdminCap for admin operations
peer: Peer, // Embedded peer config (trusted remote OApp addresses)
// ...
}
// Create and share
let oapp = OApp { /* ... */ };
transfer::share_object(oapp); // Now accessible to everyone
/// Owned object - only owner can use
public struct AdminCap has key, store {
id: UID,
}
// Transfer to admin
transfer::public_transfer(admin_cap, admin_address);
Capabilities vs msg.sender
What are Capabilities? Capabilities are special owned objects that grant specific permissions. Owning a capability object proves you have authorization to perform certain operations.
EVM Authorization uses msg.sender:
// EVM: Check caller
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function setConfig() external onlyOwner {
// only owner can call
}
Sui Authorization uses capability objects:
// Sui: Require capability object
public fun set_config(
oapp: &mut OApp,
admin_cap: &AdminCap, // Must own this object to call
config: Config,
) {
// Owning AdminCap proves authorization
// No need to check msg.sender
oapp.config = config;
}
Benefits:
- Transferable: Can give capabilities to other addresses
- Composable: Capabilities can be stored in other objects
- Type-safe: Different capabilities for different permissions
- No spoofing: Can't fake capability ownership
Programmable Transaction Blocks
While EVM executes one function call per transaction, Sui enables complex multi-step workflows in a single atomic transaction:
// EVM: Separate transactions
tx1: token.approve(spender, amount);
tx2: spender.transferFrom(user, recipient, amount);
tx3: recipient.stake(amount);
Sui uses Programmable Transaction Blocks (PTBs) - up to 1,024 commands in one atomic transaction:
const tx = new Transaction();
// All in one atomic transaction:
tx.moveCall({ target: `${pkg}::token::approve`, ... });
tx.moveCall({ target: `${pkg}::spender::transfer_from`, ... });
tx.moveCall({ target: `${pkg}::staking::stake`, ... });
await client.signAndExecuteTransaction({ transaction: tx });
For LayerZero:
- Quote fees
- Send message
- Route through Endpoint/ULN/Workers
- Confirm and extract receipt
- All in one PTB, atomically
No Dynamic Dispatch (Call Pattern)
EVM can dynamically call contracts:
// EVM: delegatecall allows dynamic invocation
contract Endpoint {
function lzReceive(address oapp, ...) {
// Call back into OApp without knowing it at compile time
(bool success, ) = oapp.delegatecall(
abi.encodeWithSignature("_lzReceive(...)", ...)
);
}
}
Sui has no dynamic dispatch. Instead, LayerZero uses the Call pattern:
/// Call object (hot potato - must be consumed)
public struct Call<Param, Result> {
// Has NO drop or store ability
// Must be explicitly destroyed
}
// Endpoint creates Call targeting OApp
public fun lz_receive(...): Call<LzReceiveParam, Void> {
call::create(executor_cap, oapp_address, true, param, ctx)
}
// OApp must destroy Call to process
public fun lz_receive(oapp: &mut OApp, call: Call<LzReceiveParam, Void>) {
let (callee, param, _) = call.destroy(&oapp.oapp_cap);
// Validate and process...
}
The Call object has no abilities at all:
- No
dropability → Can't be ignored (must be consumed) - No
storeability → Can't be saved in structs - No
copyability → Can't be forged or copied (prevents reentrancy) - No
keyability → Can't be stored globally in the ledger
This lack of abilities enforces the hot potato pattern - the Call must be explicitly destroyed before the transaction ends, routing through the PTB to the destination module. This achieves similar functionality to dynamic dispatch while maintaining type safety and preventing reentrancy attacks.
Prerequisites
Before you start building, you'll need to set up your development environment.
Install Sui CLI
Using suiup (recommended):
# Install suiup installer
curl -sSf https://raw.githubusercontent.com/MystenLabs/suiup/main/install.sh | sh
# Install Sui CLI for testnet
suiup install testnet
Verify installation:
sui --version
# sui 1.54.1-... or later
Alternatively, install via cargo:
cargo install --locked --git https://github.com/MystenLabs/sui.git --branch mainnet sui
Install Node.js and TypeScript SDK
For PTB construction and SDK usage in your OApp/OFT project:
# Install as project dependencies (not global)
npm install @mysten/sui.js @layerzerolabs/lz-sui-sdk-v2 @layerzerolabs/lz-sui-oft-sdk-v2
These packages are required for building PTBs, configuring your OApp, and interacting with deployed contracts.
Set Up Sui Wallet
Create or import a wallet:
# Create new wallet
sui client new-address ed25519
# Or import existing
sui client import <PRIVATE_KEY>
Get Testnet SUI
For testing on Sui testnet, see Sui Faucet documentation:
# Switch to testnet
sui client switch --env testnet
# Get SUI from faucet
curl --location --request POST 'https://faucet.testnet.sui.io/gas' \
--header 'Content-Type: application/json' \
--data-raw '{ "FixedAmountRequest": { "recipient": "<YOUR_ADDRESS>" } }'
Understanding Package IDs vs Object IDs
One of the most important concepts for LayerZero on Sui is the distinction between package IDs and object IDs:
| Type | What It Is | When to Use | Example |
|---|---|---|---|
| Package ID | Address of published code (immutable) | Move call targets, peer addresses | 0x061a47bf... |
| Object ID | Address of object instance (state) | Function arguments via tx.object() | 0xf1ab4be... |
Finding Package ID:
# From object type field
sui client object <OBJECT_ID> --json | jq '.data.type'
# Returns: "0xPACKAGE_ID::module::StructName"
Critical for LayerZero:
- Peer addresses = Package ID (where code is deployed)
- Not Object ID (instance of OApp/OFT)
See Peer Address Configuration for details. For general peer concepts, see Peer in Glossary.
Understanding the Registry System
When you deploy and register an OApp with the LayerZero Endpoint, understanding the registry architecture is crucial:
What happens during registration:
- Endpoint stores your package ID in its registry (not object ID)
- MessagingChannel.oapp field = your package ID
- Remote chains send messages to your package ID
- Endpoint looks up package ID → finds your MessagingChannel → routes message
Example deployment flow:
# 1. Deploy your OApp package
sui client publish --gas-budget 1000000000
# Output includes:
# - Package ID: 0x061a47bf... (your code location)
# - OApp Object ID: 0x242952... (instance of OApp)
# 2. Register with Endpoint
# Endpoint stores: registry[0x061a47bf...] = MessagingChannel
# 3. Remote chain configuration
# Remote chain must use: peer = 0x061a47bf... (package ID)
This is why peers must be package IDs.
CallCap and Package Identity
LayerZero OApps use Package CallCaps (not Individual CallCaps):
// From call_cap module
public enum CapType {
Individual, // ID = object's UID address
Package(address), // ID = package address ← OApps use this
}
// When OApp calls callCap.id():
// Returns the package address, not the object UID!
Impact on LayerZero:
callCap.id()returns your package address- Registry keys by this package address
- All lookups expect package address
- Remote chains must use this as peer address
Finding your package ID from an object:
# Method 1: From object type
sui client object 0x242952... --json | jq '.data.type'
# Output: "0x061a47bf...::oapp::OApp"
# ^^^^^^^^^^^^
# This is your package ID
# Method 2: From publish output
# Look for "packageId" in the transaction result
Common errors when using wrong ID:
| Error | Cause | Fix |
|---|---|---|
oapp_registry::get_messaging_channel abort code: 1 | Used object ID as peer | Use package ID instead |
oapp_registry::get_oapp_info abort code: 1 | Registry lookup failed | Ensure OApp is registered |
| Message delivery fails | Peer not found in registry | Verify package ID is correct |
Next Steps
Choose your path:
Build an OApp
For custom cross-chain logic:
- OApp Overview - Architecture and patterns
- OApp Protocol Details - Deep technical dive
- Technical Overview - Sui fundamentals
Build an OFT
For cross-chain tokens:
- OFT Overview - Token architecture
- OFT SDK - TypeScript SDK integration and methods
- Configuration Guide - Security and DVN setup
Understand the Protocol
For protocol-level understanding:
- Technical Overview - VM architecture and Call pattern
- Protocol Overview - Complete message workflows
- OFT SDK - Available SDK methods and patterns
Get Help
- Troubleshooting - Common issues
- FAQ - Frequently asked questions
- Discord - Community support