Skip to main content
Version: Endpoint V2

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
tip

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 drop or store abilities (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
Complete Protocol Details

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 Call object 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 Call to the appropriate module
  • Recipient processes and completes the Call
  • Caller confirms the Call to 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 TypeStorage LocationOwnership Type
EndpointEndpointV2 shared objectShared (anyone can read, admin can modify)
OApp ConfigurationOApp shared objectShared (owner via AdminCap)
OApp Peer MappingsPeer struct within OAppEmbedded (has store ability)
Messaging ChannelsMessagingChannel shared objectsShared (created per OApp)
Library ConfigsObjects within Uln302Shared object fields
Admin AuthorityAdminCap owned objectsOwned (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:

  1. Register the OApp: Call endpoint::register_oapp() to create a MessagingChannel
  2. Initialize channels: Call endpoint::init_channel() for each remote EID
  3. Set peer addresses: Call oapp::set_peer() for each destination
  4. (Optional) Set send/receive libraries (uses Endpoint defaults if not set)
  5. (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:

CapabilityTypePurpose
CallCapOwnedAuthorizes creating Call objects and calling modules
AdminCapOwnedGrants admin rights (set peers, configure options)
TreasuryCap<T>OwnedGrants mint/burn authority for coin type T
UpgradeCapOwnedAuthorizes 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.oapp field 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:

  1. Remote chain sends to your package ID
  2. Sui Endpoint looks up package ID in registry
  3. Finds your MessagingChannel
  4. 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:

  1. Call Authorization: The Call must come from the authorized Endpoint
  2. Peer Validation: Message sender must match configured peer for source EID
  3. 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 CallCap or AdminCap
  • 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

  1. No Dynamic Dispatch: Sui doesn't support dynamic dispatch; use Call pattern instead
  2. PTB-Centric: All cross-chain operations happen within Programmable Transaction Blocks
  3. Explicit Validation: Replace EVM inheritance with explicit validation checks
  4. Object-Based State: Configuration stored in object fields, not EVM-style storage slots
  5. Atomicity: PTBs guarantee all-or-nothing execution
  6. Dual Gas Model: Separate charges for storage and computation, with storage rebates

Next Steps