LayerZero IOTA L1 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 IOTA L1?
An OApp on IOTA is a Move package that integrates with the LayerZero protocol to enable cross-chain messaging. Unlike EVM OApps that inherit base contracts, IOTA OApps use shared objects and explicit function calls within Programmable Transaction Blocks (PTBs).
Differences from EVM OApps
| Aspect | EVM | IOTA |
|---|---|---|
| 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
- IOTA CLI installed (version 1.54.1 or later)
- Basic understanding of Move programming
- Familiarity with IOTA's object model
Create a New Project
Create a new IOTA Move package:
mkdir my-oapp
cd my-oapp
iota move new my_oapp
This creates:
my_oapp/
├── Move.toml
├── sources/
└── tests/
Configure Move.toml
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]
IOTA = { git = "https://github.com/iotaledger/iota.git", subdir = "crates/iota-framework/packages/iota-framework", rev = "mainnet" }
# LayerZero packages - use local paths
OApp = { local = "../LayerZero-v2/packages/layerzero-v2/iota/contracts/oapps/oapp" }
EndpointV2 = { local = "../LayerZero-v2/packages/layerzero-v2/iota/contracts/endpoint-v2" }
Call = { local = "../LayerZero-v2/packages/layerzero-v2/iota/contracts/dynamic-call/call" }
Utils = { local = "../LayerZero-v2/packages/layerzero-v2/iota/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]
IOTA = { git = "https://github.com/iotaledger/iota.git", subdir = "crates/iota-framework/packages/iota-framework", rev = "mainnet" }
# Reference by published address (check deployments page for current addresses)
OApp = { address = "0xfdc28afc0110cb2edb94e3e57f2b1ce69b5a99c503b06d15e51cfa212de56e24" }
# ... other packages
[addresses]
my_oapp = "0x0"
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 IOTA's object model and coin framework
- Production deployment examples with TypeScript SDK
Source Code References:
How OApp Messaging Works
Understanding how OApps work on IOTA requires understanding several key concepts that differ from EVM implementations.
Initialization: Creating Your OApp
When you initialize an OApp on IOTA, the oapp::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
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 IOTA'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 IOTA 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 IOTA, peers must be configured using package IDs, not object IDs:
- Your IOTA 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 IOTA 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:
- 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
Why the Call pattern? IOTA 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<IOTA>,
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:
- 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
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 IOTA, the Executor calls endpoint_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
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:
- 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 | IOTA |
|---|---|---|
| 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
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 IOTA requires four key components: