Sui Fundamentals for LayerZero Developers
This page introduces the Sui-specific concepts you need to understand before building LayerZero applications. If you're coming from EVM or Solana, this guide explains how Sui differs and why LayerZero's implementation works the way it does.
What you'll learn:
- Sui's object model vs EVM's account model
- Why dynamic dispatch doesn't work and how the Call pattern solves it
- Capabilities for authorization instead of
msg.sender - Programmable Transaction Blocks (PTBs) for atomic multi-step execution
- Gas model differences and rebate mechanism
For complete protocol workflows with detailed code, see Protocol Overview. For hands-on implementation, see OApp or OFT guides.
VM Architecture
Sui uses the Move programming language and employs an object-based model rather than the account-based model used by EVM chains. This fundamental difference requires different patterns for implementing cross-chain functionality.
Sui Object Model
Sui organizes state into objects with different ownership types. For an introduction to Sui's object model, see Getting Started.
LayerZero uses all three ownership types:
- Shared:
EndpointV2,MessagingChannel,OApp,OFT<T>(accessible by anyone, mutable by authorized) - Owned:
AdminCap,CallCap(belong to specific address, used for authorization) - Immutable: Published packages,
CoinMetadata<T>(read-only, never change)
Each object has:
- Unique ID (
UID): Globally unique identifier - Abilities: Define what operations are allowed (
key,store,copy,drop) - Type: Determines structure and behavior
No Dynamic Dispatch
Unlike EVM chains that support dynamic dispatch through delegatecall, Sui does not support dynamic dispatch. Function calls must target modules known at compile time.
Why this matters: The LayerZero Endpoint needs to call back into OApp modules whose addresses vary per deployment—not known when the Endpoint is published. This architectural constraint requires a different approach.
Call Pattern (Hot Potato)
LayerZero solves the dynamic dispatch limitation using a capability-based pattern called "hot potato."
To achieve dynamic routing, LayerZero uses the Call pattern—a capability-based hot potato implementation.
The Call<Param, Result> struct:
- Has no
droporstoreabilities (cannot be ignored or saved) - Can only be created by the caller module
- Must be consumed by the designated callee
- Enforces proper sequencing through lifecycle states
- Returns results back to the caller
Call Lifecycle:
Active → Creating (child calls) → Waiting → Active → Completed → Destroyed
This ensures atomicity: if any step fails, the entire PTB reverts.
Programmable Transaction Blocks (PTBs)
Sui's execution model centers around Programmable Transaction Blocks—atomic command sequences that:
- Execute multiple Move function calls
- Pass objects and results between calls
- Guarantee all-or-nothing execution
- Enable complex multi-contract workflows
- Support up to 1024 commands per block
Message Flow Overview
LayerZero messages on Sui flow through multiple modules using the Call pattern within a Programmable Transaction Block.
High-Level Flow:
Send: OApp → Endpoint → ULN302 → Workers → Confirmation chain
Receive: Executor → Endpoint (clear) → OApp (validate & process)
Key Mechanisms:
- Call pattern: Dynamic routing through
Call<Param, Result>objects - PTB coordination: All steps happen atomically in one transaction
- Capability validation: Each module validates CallCap ownership
- Storage management: MessagingChannel tracks nonces and payload hashes
For detailed send/verify/receive workflows with contract code, struct definitions, and transaction analysis, see Protocol Overview.
Transaction Execution Model
Sui supports two types of function calls, each serving different purposes in the LayerZero protocol.
Static Calls
Used when the target module is known at compile time:
- Direct function invocation within a PTB
- No intermediate
Callobject needed - Example: OApp calling Endpoint (Endpoint object ID is known)
// Direct call (static)
endpoint::init_channel(&mut endpoint, &call_cap, remote_eid);
Call Pattern (Dynamic Routing)
Used when the target module is not known at compile time:
- Caller creates a
Call<Param, Result>object - PTB routes the
Callto the appropriate module - Recipient processes and completes the
Call - Caller confirms the
Callto extract results - Example: Endpoint routing to OApp (OApp object ID varies per deployment)
// Create Call
let call = oapp::lz_send(&mut oapp, &call_cap, ...);
// PTB routes Call through Endpoint
// Confirm to extract results
let (_, receipt) = oapp::confirm_lz_send(&oapp, &call_cap, call);
Atomicity Guarantees
All operations within a PTB are atomic:
- If any step fails, the entire transaction reverts
- No partial state changes
- Enables complex multi-step operations with safety guarantees
For Implementation Details: See Protocol Overview for complete workflows including:
- Nonce management and packet construction
- Worker assignment and fee aggregation
- DVN verification and threshold checking
- Message delivery and payload clearing
State Management Model
LayerZero on Sui uses shared and owned objects to manage configuration and message state, rather than EVM-style storage slots.
LayerZero State Storage
State is organized into objects with different ownership types, each serving specific purposes:
| State Type | Storage Location | Ownership Type |
|---|---|---|
| Endpoint | EndpointV2 shared object | Shared (anyone can read, admin can modify) |
| OApp Configuration | OApp shared object | Shared (owner via AdminCap) |
| OApp Peer Mappings | Peer struct within OApp | Embedded (has store ability) |
| Messaging Channels | MessagingChannel shared objects | Shared (created per OApp) |
| Library Configs | Objects within Uln302 | Shared object fields |
| Admin Authority | AdminCap owned objects | Owned (transferable to new admin) |
Key Concepts:
- Shared objects: Created with
transfer::share_object(), accessible to all transactions - Owned objects: Created with
transfer::transfer(), belong to specific addresses - Embedded structs: Fields within objects (e.g.,
Peer,EnforcedOptions) - Tables: Dynamic collections stored within objects (e.g., peer mappings by EID)
Object-Based Configuration
Configuration is stored in struct fields and Tables, not storage slots:
public struct OApp has key {
id: UID,
oapp_cap: CallCap, // Capability for calls
admin_cap: address, // Reference to AdminCap owner
peer: Peer, // Embedded peer mappings (Table<u32, Bytes32>)
enforced_options: EnforcedOptions, // Embedded options config
sending_call: Option<address>, // Track in-progress sends
}
Initialization Requirements
Before sending messages, you must:
- Register the OApp: Call
endpoint::register_oapp()to create aMessagingChannel - Initialize channels: Call
endpoint::init_channel()for each remote EID - Set peer addresses: Call
oapp::set_peer()for each destination - (Optional) Set send/receive libraries (uses Endpoint defaults if not set)
- (Optional) Configure ULN parameters (uses library defaults if not set)
Security & Permission Model
Sui's security model differs fundamentally from EVM's msg.sender approach, using owned objects to prove authorization.
Capability-Based Authorization
Instead of checking the transaction sender, Sui functions require capability objects as parameters:
| Capability | Type | Purpose |
|---|---|---|
CallCap | Owned | Authorizes creating Call objects and calling modules |
AdminCap | Owned | Grants admin rights (set peers, configure options) |
TreasuryCap<T> | Owned | Grants mint/burn authority for coin type T |
UpgradeCap | Owned | Authorizes package upgrades |
Capability Pattern:
public fun set_peer(
self: &mut OApp,
admin_cap: &AdminCap, // Must provide AdminCap to prove authorization
eid: u32,
peer: Bytes32,
)
CallCap Type System
CallCap objects have two types that determine their identifier:
/// From call_cap module
public enum CapType {
Individual, // ID = UID address (object-specific)
Package(address), // ID = Package address (package-wide)
}
public fun id(self: &CallCap): address {
match (self.cap_type) {
CapType::Individual => self.id.to_address(), // Returns object UID
CapType::Package(package) => package, // Returns package address!
}
}
LayerZero OApps/OFTs use Package CallCaps:
// Created with one-time witness
call_cap::new_package_cap(&otw, ctx) // Creates Package type
// Returns package address
oapp_cap.id() // → package address, not object UID
Why This Matters:
The registry architecture explains why package IDs are used throughout:
/// From oapp_registry.move
public struct OAppRegistry has store {
// Maps OApp package address to its complete information
oapps: Table<address, OAppRegistration>, // ← Keyed by package address!
}
public(package) fun get_messaging_channel(
self: &OAppRegistry,
oapp: address // Package address expected
): address {
let registration = table_ext::borrow_or_abort!(&self.oapps, oapp, EOAppNotRegistered);
registration.messaging_channel
}
Impact on LayerZero:
- Registry lookups use
callCap.id()→ package address MessagingChannel.oappfield stores package address- Peer addresses must be package IDs (not object IDs)
- Verification checks receiver against package address
- Remote chains send to package address, not object
This is the fundamental reason why Sui peer addresses are package IDs, not object IDs.
Why This Matters for Configuration
When you deploy an OApp/OFT and configure peers:
On Sui side:
import {SDK} from '@layerzerolabs/lz-sui-sdk-v2';
import {Stage} from '@layerzerolabs/lz-definitions';
const sdk = new SDK({client, stage: Stage.MAINNET});
const oapp = sdk.getOApp(yourPackageId); // Package ID, not object ID!
On remote EVM side:
// Use Sui PACKAGE ID as peer
myOApp.setPeer(
30378, // Sui mainnet EID
bytes32(0x061a47bf...) // Your Sui PACKAGE ID
);
What happens when message arrives:
- Remote chain sends to your package ID
- Sui Endpoint looks up package ID in registry
- Finds your MessagingChannel
- Routes message to your OApp object
This registry architecture is why peers must be package IDs.
Validation Pattern
OApps validate CallCap ownership to ensure calls are authorized:
public fun send(
self: &OApp,
oapp_cap: &CallCap, // Proves caller owns this OApp
...
) {
self.assert_oapp_cap(oapp_cap); // Validates CallCap belongs to this OApp
// ... business logic
}
fun assert_oapp_cap(self: &OApp, cap: &CallCap) {
assert!(self.oapp_cap.id() == cap.id(), EInvalidOAppCap);
}
This replaces Solidity's inheritance-based validation with explicit capability checks.
Receive Path Security
When receiving messages, the OApp validates:
- Call Authorization: The
Callmust come from the authorized Endpoint - Peer Validation: Message sender must match configured peer for source EID
- Message Integrity: DVNs have verified the message before delivery
public fun lz_receive(
self: &mut OApp,
call: Call<LzReceiveParam, Void>,
) {
// Validate Call came from Endpoint
let (callee, param, _) = call.destroy(&self.oapp_cap);
assert!(callee == endpoint_address(), EOnlyEndpoint);
// Validate sender is the configured peer
let peer = self.peer.get_peer(param.src_eid);
assert!(param.sender == peer, EOnlyPeer);
// Process message...
}
Common Security Risks
- Missing capability validation: Not checking
CallCaporAdminCap - Capability loss: Transferring or losing owned capability objects
- Incorrect peer configuration: Setting wrong peer addresses
- Bypassing validation: Skipping
assert_oapp_cap()checks
Gas Model
Sui's gas system differs from EVM by separating storage and computation costs, with a unique rebate mechanism.
Storage Gas
- Charged for storing data on-chain
- Rebate mechanism: When storage is freed, gas is refunded
- This can result in negative gas utilization for transactions that free storage
Computation Gas
- Charged for execution/computation
- Base Budget: Every transaction requires a minimum of 1000 gas units
- Priority fees can be added during network congestion
For detailed gas information, see:
Key Sui Concepts for LayerZero
Sui provides system-level objects and features that LayerZero leverages for cross-chain messaging.
Clock Object
The Clock is a system singleton object at address 0x6:
// Access in functions
public fun some_function(clock: &Clock) {
let timestamp_ms = clock.timestamp_ms();
// Use for timeout validation, rate limiting, etc.
}
// In PTB
tx.object('0x6') // Reference to Clock
Used in LayerZero for:
- Library timeout validation
- Rate limiter windows
- Message expiration checks
Event System
Sui events are emitted and indexed for off-chain monitoring:
use sui::event;
public struct MessageSentEvent has copy, drop {
guid: Bytes32,
dst_eid: u32,
message: vector<u8>,
}
// Emit event
event::emit(MessageSentEvent { guid, dst_eid, message });
Monitoring Events:
// Subscribe to events
const unsubscribe = await client.subscribeEvent({
filter: {Package: packageId},
onMessage: (event) => {
console.log('Event:', event);
},
});
Key Takeaways
- No Dynamic Dispatch: Sui doesn't support dynamic dispatch; use Call pattern instead
- PTB-Centric: All cross-chain operations happen within Programmable Transaction Blocks
- Explicit Validation: Replace EVM inheritance with explicit validation checks
- Object-Based State: Configuration stored in object fields, not EVM-style storage slots
- Atomicity: PTBs guarantee all-or-nothing execution
- Dual Gas Model: Separate charges for storage and computation, with storage rebates
Next Steps
- OApp Implementation Guide - Build custom cross-chain applications
- OFT Implementation Guide - Deploy cross-chain tokens
- OFT SDK - TypeScript SDK methods and patterns
- Configuration Guide - DVN and executor configuration
- Protocol Overview - Complete protocol workflows
- Sui Development Guidance - Best practices