Skip to main content
Version: Endpoint V2

IOTA L1 OFT

The Omnichain Fungible Token (OFT) Standard allows fungible tokens to be transferred across multiple blockchains without asset wrapping or middlechains. Read more on OFTs in our glossary page: OFT.

What is an OFT on IOTA?

An OFT on IOTA is a Move package that extends the OApp functionality to enable cross-chain token transfers. It integrates with IOTA's native coin type system (Coin<T>, Balance<T>, TreasuryCap<T>) while providing LayerZero's omnichain capabilities.

This guide will walk you through deploying an OFT on IOTA L1. To understand how OFTs integrate with IOTA L1's coin system and the differences between mint/burn and lock/unlock token management strategies, see Integration with IOTA L1 Coin System.

Deployment

OFT deployment on IOTA uses a two-package pattern: your token + pure LayerZero OFT source.

Mint/Burn Example

This deployment guide demonstrates the mint/burn approach, where you provide the TreasuryCap and the OFT mints/burns tokens during cross-chain transfers. This works for both new tokens and existing tokens where you control the TreasuryCap.

If you DON'T have the TreasuryCap (frozen, held by DAO, etc.), use the lock/unlock (adapter) approach instead. See Choosing Mint/Burn vs Lock/Unlock for details.

Prerequisites:

Install IOTA CLI

# macOS (Homebrew)
brew tap iotaledger/tap
brew install iota

# Verify installation
iota --version

For other platforms, see the IOTA Installation Guide.

Create Wallet and Get IOTA

# Create new wallet (interactive prompts)
iota client new-address

# Switch to mainnet or testnet
iota client switch --env mainnet
iota client switch --env testnet

# Check your address
iota client active-address

# Request testnet IOTA from faucet
iota client faucet

# Verify balance before proceeding (wait a few seconds after faucet)
iota client gas

To fund your wallet, use the IOTA faucet for testnet or acquire IOTA from an exchange for mainnet.

New to IOTA?

If you haven't used IOTA before, start with Getting Started with IOTA to understand the object model, package structure, and development basics.

Step 1: Create and Deploy Your Token

Create token package:

mkdir my-token
cd my-token
iota move new myoft

Implement token (sources/myoft.move):

module myoft::myoft;

use iota::coin;

/// One-time witness for coin creation
/// Must be named same as module (MYOFT) and have only `drop` ability
public struct MYOFT has drop {}

/// Initialize the coin on package publish
fun init(otw: MYOFT, ctx: &mut TxContext) {
// Create the coin with metadata
let (treasury_cap, coin_metadata) = coin::create_currency(
otw, // One-time witness
6, // decimals (6 for cross-chain compatibility)
b"MYOFT", // symbol
b"My Omnichain Fungible Token", // name
b"A LayerZero OFT on IOTA with mint/burn capabilities", // description
option::none(), // icon_url (optional)
ctx
);

// Freeze the metadata object (makes it immutable and shared)
transfer::public_freeze_object(coin_metadata);

// Transfer treasury cap to deployer
transfer::public_transfer(treasury_cap, ctx.sender());
}

// That's it! No OFT logic in token package

Deploy your token:

Gas Budgets

Gas budgets are specified in NANOS (1 IOTA = 1,000,000,000 NANOS). The budget is the maximum you're willing to spend; actual costs are typically much lower:

  • Token package: ~0.5 IOTA budget, actual cost ~0.013 IOTA
  • OFT package: ~1 IOTA budget, actual cost ~0.186 IOTA
# From your token directory
iota client publish --gas-budget 500000000 --json > token_deploy.json

# Extract IDs
TOKEN_PACKAGE=$(jq -r '.objectChanges[] | select(.type=="published") | .packageId' token_deploy.json)
TREASURY_CAP=$(jq -r '.objectChanges[] | select(.objectType | contains("TreasuryCap")) | .objectId' token_deploy.json)
COIN_METADATA=$(jq -r '.objectChanges[] | select(.objectType | contains("CoinMetadata")) | .objectId' token_deploy.json)

echo "Token Package: $TOKEN_PACKAGE"
echo "Treasury Cap: $TREASURY_CAP"
echo "Coin Metadata: $COIN_METADATA"

Step 2: Deploy LayerZero OFT Package

Deploy the pure LayerZero OFT source without modifications.

Recommended approach (git dependencies):

# Copy OFT source to your project
mkdir oft
cd oft

Create Move.toml with git dependencies:

[package]
name = "OFT"
version = "0.0.1"
edition = "2024.beta"
license = "MIT"

[dependencies]
OApp = { git = "https://github.com/LayerZero-Labs/LayerZero-v2.git", subdir = "packages/layerzero-v2/iota/contracts/oapps/oapp", rev = "main" }
OFTCommon = { git = "https://github.com/LayerZero-Labs/LayerZero-v2.git", subdir = "packages/layerzero-v2/iota/contracts/oapps/oft/oft-common", rev = "main" }
PtbMoveCall = { git = "https://github.com/LayerZero-Labs/LayerZero-v2.git", subdir = "packages/layerzero-v2/iota/contracts/ptb-builders/ptb-move-call", rev = "main" }

[addresses]
oft = "0x0"

[dev-dependencies]
SimpleMessageLib = { git = "https://github.com/LayerZero-Labs/LayerZero-v2.git", subdir = "packages/layerzero-v2/iota/contracts/message-libs/simple-message-lib", rev = "main" }

Copy OFT source files:

# Clone LayerZero repository (temporary, just to copy sources)
git clone https://github.com/LayerZero-Labs/LayerZero-v2.git --depth 1
cp -r LayerZero-v2/packages/layerzero-v2/iota/contracts/oapps/oft/oft/sources ./
rm -rf LayerZero-v2

Deploy (dependencies auto-fetched from GitHub):

# From your OFT directory
iota client publish --gas-budget 1000000000 --json > oft_deploy.json

# Extract the package ID (you'll need this for peer configuration!)
OFT_PACKAGE=$(jq -r '.objectChanges[] | select(.type=="published") | .packageId' oft_deploy.json)
OAPP_OBJECT=$(jq -r '.objectChanges[] | select(.objectType | contains("::oapp::OApp")) | select(.owner.Shared) | .objectId' oft_deploy.json)
INIT_TICKET=$(jq -r '.objectChanges[] | select(.objectType | contains("OFTInitTicket")) | .objectId' oft_deploy.json)

echo "OFT Package: $OFT_PACKAGE" # ← SAVE THIS! Use as peer on remote chains
echo "OApp Object: $OAPP_OBJECT"
echo "Init Ticket: $INIT_TICKET"

This automatically creates an OFTInitTicket (via oft_impl::init()).

Save Your Package ID!

The OFT Package ID (not object ID) is what you'll use as the peer address on remote chains. Remote chains must use this package ID to send messages to your IOTA OFT.

Finding package ID from object (if you didn't save it):

iota client object <OFT_OBJECT_ID> --json | jq -r '.data.type' | cut -d':' -f1

Alternative: Deploy directly from the cloned repository using --with-unpublished-dependencies flag (requires all dependencies in correct relative paths).

Multiple OFTs

If deploying multiple OFTs (e.g., different tokens), repeat this OFT package deployment for each token. Each token gets its own OFT package instance. For adapter OFTs, see choosing between mint/burn and lock/unlock models to avoid deploying multiple adapters for the same token.

Step 3: Initialize OFT via SDK

Consume the ticket using the OFT SDK. This example uses mint/burn initialization by passing the TreasuryCap:

import {IotaClient} from '@iota/iota-sdk/client';
import {Transaction} from '@iota/iota-sdk/transactions';
import {Ed25519Keypair} from '@iota/iota-sdk/keypairs/ed25519';
import {SDK} from '@layerzerolabs/lz-iotal1-sdk-v2';
import {OFT} from '@layerzerolabs/lz-iotal1-oft-sdk-v2';
import {Stage} from '@layerzerolabs/lz-definitions';

// Initialize IOTA client
const client = new IotaClient({url: 'https://api.mainnet.iota.cafe'});

// Load keypair from private key (supports bech32 'iotaprivkey1...' or hex '0x...' format)
const keypair = Ed25519Keypair.fromSecretKey(/* your private key bytes */);
const sender = keypair.toIotaAddress();

const sdk = new SDK({client, stage: Stage.MAINNET});
const oft = new OFT(sdk, OFT_PKG, undefined, TOKEN_TYPE, OAPP);

const initTx = new Transaction();
// Use initOftMoveCall for mint/burn (includes TreasuryCap)
const [adminCap, migrationCap] = oft.initOftMoveCall(
initTx,
TOKEN_TYPE, // "0xTOKEN_PKG::myoft::MYOFT"
TICKET, // OFTInitTicket object ID
OAPP, // OApp object ID
TREASURY, // TreasuryCap object ID (enables mint/burn)
METADATA, // CoinMetadata object ID
6, // shared_decimals
);
initTx.transferObjects([adminCap, migrationCap], sender);

const result = await client.signAndExecuteTransaction({
transaction: initTx,
signer: keypair,
options: {showObjectChanges: true},
});

// Extract OFT object ID
const OFT_OBJECT = result.objectChanges.find(
(c) => c.type === 'created' && c.objectType.includes('oft::OFT<'),
).objectId;

await client.waitForTransaction({digest: result.digest});
Lock/Unlock Alternative

To initialize an OFT Adapter for an existing token (lock/unlock model), use oft.initOftAdapterMoveCall() instead, which does not require the TREASURY parameter. See Integration with IOTA Coin System for details.

Integration with IOTA L1 Coin System

OFTs integrate seamlessly with IOTA L1's native coin framework, using standard types for token management.

IOTA L1 Coin Type System

The IOTA L1 framework provides these core types for token functionality:

Coin<T>: Owned coin object with a value

public struct Coin<phantom T> has key, store {
id: UID,
balance: Balance<T>,
}

Balance<T>: Storable value (can be held in structs)

public struct Balance<phantom T> has store {
value: u64,
}

TreasuryCap<T>: Authority to mint/burn coins

public struct TreasuryCap<phantom T> has key, store {
id: UID,
total_supply: Supply<T>,
}

CoinMetadata<T>: Token information (name, symbol, decimals)

public struct CoinMetadata<T> has key, store {
id: UID,
decimals: u8,
name: string::String,
symbol: ascii::String,
description: string::String,
icon_url: Option<Url>,
}

OFT Integration

The OFT uses these types:

public struct OFT<phantom T> has key {
// ...
treasury: OFTTreasury<T>, // Holds TreasuryCap OR Balance escrow
coin_metadata: address, // Reference to CoinMetadata<T> object
// ...
}

Phantom Type Parameter: <phantom T> means:

  • T is the coin type (e.g., MY_COIN)
  • phantom = T doesn't appear in any field directly
  • Enables type safety without storing T values

OFT Types

IOTA OFTs use a flexible enum pattern that supports two token management strategies, depending on whether you're creating a new token or bridging an existing one.

OFT Structure

The OFT uses a generic type parameter and includes built-in support for optional features:

/// Omnichain Fungible Token - enables seamless cross-chain token transfers
public struct OFT<phantom T> has key {
id: UID,
upgrade_version: u64,
oapp_object: address, // Associated OApp for messaging
admin_cap: address, // AdminCap owner address
migration_cap: address, // Migration capability
oft_cap: CallCap, // Capability for cross-chain calls
treasury: OFTTreasury<T>, // ← Enum: determines mint/burn vs lock/unlock
coin_metadata: address, // Reference to CoinMetadata<T>
decimal_conversion_rate: u64, // 10^(local - shared decimals)
shared_decimals: u8, // Cross-chain precision

// Optional features (always present, opt-in to configure)
pausable: Pausable, // Starts unpaused (false)
fee: OFTFee, // Starts with 0% fees
inbound_rate_limiter: RateLimiter, // Starts with no limits
outbound_rate_limiter: RateLimiter, // Starts with no limits
}

All OFTs include these fields, but they start in safe default states. Configuration is optional and done via admin functions after deployment.

Treasury Enum

The OFTTreasury<T> enum determines token management strategy:

public enum OFTTreasury<phantom T> has store {
/// Standard OFT: mints/burns using treasury capability
OFT {
treasury_cap: TreasuryCap<T>, // Grants mint/burn authority
},
/// Adapter OFT: escrows/releases existing tokens
OFTAdapter {
escrow: Balance<T>, // Token balance pool
},
}

Choosing Mint/Burn vs Lock/Unlock

ModelWhen to UseInitialization Method
Mint/BurnYou have/control the TreasuryCap<T>oft.initOftMoveCall() with TREASURY parameter
Lock/UnlockYou DON'T have the TreasuryCap<T>oft.initOftAdapterMoveCall() without TREASURY

1. Mint/Burn

This model manages token supply by minting new tokens on the destination chain and burning them on the source chain.

When to use:

  • You own or can obtain the TreasuryCap<T> for the token
  • You're comfortable with dynamic supply distribution across chains
  • Works for both new tokens AND existing tokens where you control the TreasuryCap
TreasuryCap on IOTA

On IOTA, TreasuryCap<T> is an owned object that can be transferred between addresses. If you created a token previously or received the TreasuryCap from someone else, you can use the mint/burn model even for "existing" tokens. Only addresses with access to the TreasuryCap can mint and burn the token supply.

Mechanism:

  • Send: Burns tokens on source chain (reduces total supply)
  • Receive: Mints tokens on destination chain (increases total supply)

Initialization (via SDK):

// SDK handles internal treasury enum construction
const [adminCap, migrationCap] = oft.initOftMoveCall(
initTx,
TOKEN_TYPE,
TICKET,
OAPP,
TREASURY, // ← Your TreasuryCap transferred to OFT internally
METADATA,
6, // shared_decimals
);

2. Lock/Unlock

The lock/unlock model enables omnichain bridging by escrowing tokens on the source chain and releasing them on the destination, maintaining fixed supply on IOTA L1.

When to use:

  • You DON'T have access to the TreasuryCap<T> (frozen, held by DAO, or inaccessible)
  • Token supply on IOTA must remain fixed
  • You need to bridge a token where you lack mint/burn authority

Mechanism:

  • Send: Locks tokens in OFT's escrow balance (removes from circulation)
  • Receive: Releases tokens from escrow balance (returns to circulation)

Initialization (via SDK):

// SDK handles internal treasury enum construction
const [adminCap, migrationCap] = oft.initOftAdapterMoveCall(
initTx,
TOKEN_TYPE,
TICKET,
OAPP,
METADATA, // ← No TreasuryCap needed for adapter
6, // shared_decimals
);
warning

Only deploy one OFT Adapter per token mesh. Multiple adapters fragment liquidity and can lead to token loss if supply is insufficient on the destination chain.

Core Operations

The core operations of an Omnichain Fungible Token (OFT) on IOTA enable seamless value transfer across multiple blockchains. At a high level, these consist of sending tokens to another chain and receiving them from peers, all while maintaining strict security and interoperability guarantees.

Sending Tokens

Sending tokens is the primary function OFTs provide, allowing users to transfer assets from the current chain to a specified recipient on a different blockchain. This operation burns or locks tokens on the source chain, constructs a cross-chain message, and leverages the LayerZero protocol to initiate delivery to the destination chain.

public fun send<T>(
self: &mut OFT<T>,
oapp: &mut OApp,
sender: &OFTSender, // Authorization context (from oft_sender module)
send_param: &SendParam, // Complete send parameters
coin_provided: &mut Coin<T>, // Coin to debit tokens from
native_coin_fee: Coin<IOTA>, // Fee payment in IOTA
zro_coin_fee: Option<Coin<ZRO>>, // Optional ZRO payment
refund_address: Option<address>, // Optional refund address
clock: &Clock, // Clock for rate limiting
ctx: &mut TxContext,
): (Call<EndpointSendParam, MessagingReceipt>, OFTSendContext)

Returns: A tuple containing:

  1. Call<EndpointSendParam, MessagingReceipt> - Route through Endpoint, then confirm
  2. OFTSendContext - Context for confirming the send operation

Process:

  1. Debit tokens from sender's coin (burns or escrows based on OFT type)
  2. Apply fee if configured, remove dust for decimal precision
  3. Build OFT message with recipient and amount in shared decimals
  4. Create Call to send via LayerZero Endpoint
  5. (Optional) Rate limiter tracks outbound flow

Receiving Tokens

Receiving tokens on IOTA involves securely processing incoming cross-chain messages, validating the source and payload, and minting or unlocking tokens to deliver them to the intended recipient.

public fun lz_receive<T>(
self: &mut OFT<T>,
oapp: &OApp, // Associated OApp for validation
call: Call<LzReceiveParam, Void>,// Call from Executor via Endpoint
clock: &Clock, // Clock for rate limiting
ctx: &mut TxContext,
)

Process:

  1. Executor delivers Call object via Endpoint
  2. OApp validates Call came from authorized Endpoint and peer
  3. OFT decodes message to extract recipient and amount in shared decimals
  4. Converts amount to local decimals
  5. Credits tokens (mints or releases from escrow based on OFT type)
  6. Rate limiter tracks inbound flow
  7. Transfers credited tokens to recipient

For compose functionality: Use lz_receive_with_compose() which additionally requires:

  • compose_queue: &mut ComposeQueue
  • composer_manager: &mut OFTComposerManager

Decimal Precision

OFTs use local decimals (per-chain precision) and shared decimals (cross-chain precision) to handle token transfers across blockchains with different decimal standards. For complete details on how this works, see OFT Technical Reference.

IOTA-Specific Constraint: u64 Balance Limit

u64 Balance Overflow

IOTA's coin framework uses u64 for all token balances, imposing a hard limit of 2^64 - 1 = 18,446,744,073,709,551,615. If you attempt to mint or transfer amounts exceeding this value, the transaction will abort. This is a blockchain VM constraint that cannot be bypassed.

Impact on decimals:

Maximum supply = (2^64 - 1) / (10^decimals)

Choose your decimals carefully during token deployment.

Local DecimalsMax Total SupplyRecommendation
6~18.4 trillion✅ Recommended
9~18.4 billion✅ Recommended
18 (EVM standard)~18 whole tokens❌ Avoid on IOTA

Shared Decimals: Use 6 (default) for most use cases.

Deployment Planning

Before calling coin::create_currency():

  1. Calculate your maximum token supply
  2. Choose local decimals: Ensure max_supply * 10^decimals < 2^64
  3. Use shared_decimals = 6 during OFT initialization (standard)

For detailed information on shared decimals, decimal conversion, and dust handling, see OFT Technical Reference.

Registration with Endpoint

After initializing your OFT, you must register it with the LayerZero Endpoint to enable cross-chain messaging.

Using OFT SDK

import {IotaClient} from '@iota/iota-sdk/client';
import {Transaction} from '@iota/iota-sdk/transactions';
import {Ed25519Keypair} from '@iota/iota-sdk/keypairs/ed25519';
import {SDK} from '@layerzerolabs/lz-iotal1-sdk-v2';
import {OFT} from '@layerzerolabs/lz-iotal1-oft-sdk-v2';
import {Stage} from '@layerzerolabs/lz-definitions';

// Initialize IOTA client
const client = new IotaClient({url: 'https://api.mainnet.iota.cafe'});
// For testnet: const client = new IotaClient({ url: 'https://api.testnet.iota.cafe' });

const sdk = new SDK({client, stage: Stage.MAINNET});
const oft = new OFT(sdk, OFT_PKG, OFT_OBJECT, TOKEN_TYPE, OAPP);

const regTx = new Transaction();

// SDK auto-generates lz_receive_info internally!
await oft.registerOAppMoveCall(
regTx,
TOKEN_TYPE, // "0xTOKEN_PKG::myoft::MYOFT"
OFT_OBJECT, // OFT object ID
OAPP, // OApp object ID
'0xfe5be5a2d5b11e635e3e4557bb125fb24a3dd09111eded06fd6058b2aee1d054', // OFTComposerManager (IOTA mainnet)
);

const regResult = await client.signAndExecuteTransaction({
transaction: regTx,
signer: keypair,
options: {showObjectChanges: true},
});

// Wait for finality
await client.waitForTransaction({digest: regResult.digest});

console.log('✅ Registration complete:', regResult.digest);

What this does:

  • Creates MessagingChannel shared object
  • Stores registry entry keyed by your package ID
  • Auto-generates proper lz_receive_info with all required PTB instructions
  • No manual info generation needed!

OFTComposerManager: This shared object routes compose messages to appropriate handlers.

OFTComposerManager address on mainnet: 0xfe5be5a2d5b11e635e3e4557bb125fb24a3dd09111eded06fd6058b2aee1d054 OFTComposerManager address on testnet: 0x90384f5f6034604f76ac99bbdd25bc3c9c646a6e13a27f14b530733a8e98db99

Finding Current Addresses

The canonical addresses are available in the SDK deployment files at @layerzerolabs/lz-iotal1-sdk-v2/deployments/iotal1-mainnet/object-OFTComposerManager.json. If you encounter TypeError: Cannot convert undefined to a BigInt during registration, verify you're using the correct OFTComposerManager address for your network.

Configuration

After registration, configure your OFT to enable cross-chain token transfers.

Using OApp SDK for Configuration on IOTA

All configuration is done through the base SDK's OApp instance. Configure security settings before setting peers to open the pathway.

Endpoint IDs

IOTA L1 Endpoint IDs:

  • IOTA Mainnet: 30423
  • IOTA Testnet: 40423

The examples below use EID 30184 (Base Mainnet) as the destination. For a complete list of endpoint IDs across all supported chains, see Deployed Contracts.

import {IotaClient} from '@iota/iota-sdk/client';
import {Transaction} from '@iota/iota-sdk/transactions';
import {Ed25519Keypair} from '@iota/iota-sdk/keypairs/ed25519';
import {
SDK,
PACKAGE_ULN_302_ADDRESS,
OBJECT_ULN_302_ADDRESS,
PACKAGE_DVN_LAYERZERO_ADDRESS,
OAppUlnConfigBcs,
} from '@layerzerolabs/lz-iotal1-sdk-v2';
import {OFT} from '@layerzerolabs/lz-iotal1-oft-sdk-v2';
import {Options} from '@layerzerolabs/lz-v2-utilities';
import {Stage} from '@layerzerolabs/lz-definitions';

// Initialize client and keypair (see Step 3 for details)
const client = new IotaClient({url: 'https://api.mainnet.iota.cafe'});
const keypair = Ed25519Keypair.fromSecretKey(/* your private key bytes */);

const sdk = new SDK({client, stage: Stage.MAINNET});
const oapp = sdk.getOApp(OFT_PKG); // Use OFT package ID
const oft = new OFT(sdk, OFT_PKG, OFT_OBJECT, TOKEN_TYPE, OAPP);

// Step 1: Set Send Library (recommended - custom send message library)
const sendLibTx = new Transaction();
await oapp.setSendLibraryMoveCall(
sendLibTx,
30184, // Destination EID
customSendLibraryAddress,
);
await client.signAndExecuteTransaction({transaction: sendLibTx, signer: keypair});

// Step 1: Set Receive Library (recommended - custom receive message library)
const receiveLibTx = new Transaction();
await oapp.setReceiveLibraryMoveCall(
receiveLibTx,
30184, // Source EID
customReceiveLibraryAddress,
0, // Grace period
);
await client.signAndExecuteTransaction({transaction: receiveLibTx, signer: keypair});

// Step 2: Configure Receive DVN (recommended - receive verification)
const receiveConfig = OAppUlnConfigBcs.serialize({
use_default_confirmations: false,
use_default_required_dvns: false,
use_default_optional_dvns: true,
uln_config: {
confirmations: 15,
required_dvns: [PACKAGE_DVN_LAYERZERO_ADDRESS[Stage.MAINNET]],
optional_dvns: [],
optional_dvn_threshold: 0,
},
}).toBytes();

const receiveConfigTx = new Transaction();
const receiveConfigCall = await oapp.setConfigMoveCall(
receiveConfigTx,
PACKAGE_ULN_302_ADDRESS[Stage.MAINNET],
30184, // Destination EID
3, // CONFIG_TYPE_RECEIVE_ULN
receiveConfig,
);

receiveConfigTx.moveCall({
target: `${PACKAGE_ULN_302_ADDRESS[Stage.MAINNET]}::uln_302::set_config`,
arguments: [receiveConfigTx.object(OBJECT_ULN_302_ADDRESS[Stage.MAINNET]), receiveConfigCall],
});

await client.signAndExecuteTransaction({transaction: receiveConfigTx, signer: keypair});

// Step 2: Configure Send DVN (recommended - send verification)
const sendConfig = OAppUlnConfigBcs.serialize({
use_default_confirmations: false,
use_default_required_dvns: false,
use_default_optional_dvns: true,
uln_config: {
confirmations: 15,
required_dvns: [PACKAGE_DVN_LAYERZERO_ADDRESS[Stage.MAINNET]],
optional_dvns: [],
optional_dvn_threshold: 0,
},
}).toBytes();

const sendConfigTx = new Transaction();
const sendConfigCall = await oapp.setConfigMoveCall(
sendConfigTx,
PACKAGE_ULN_302_ADDRESS[Stage.MAINNET],
30184,
2, // CONFIG_TYPE_SEND_ULN (outbound messages)
sendConfig,
);

sendConfigTx.moveCall({
target: `${PACKAGE_ULN_302_ADDRESS[Stage.MAINNET]}::uln_302::set_config`,
arguments: [sendConfigTx.object(OBJECT_ULN_302_ADDRESS[Stage.MAINNET]), sendConfigCall],
});

await client.signAndExecuteTransaction({transaction: sendConfigTx, signer: keypair});

// Step 3: Set Enforced Options (optional - minimum gas requirements)
const options = Options.newOptions()
.addExecutorLzReceiveOption(80000, 0) // 80k gas for destination
.toBytes();

const optionsTx = new Transaction();
await oapp.setEnforcedOptionsMoveCall(
optionsTx,
30184, // Destination EID
1, // Message type (1 = SEND)
options,
);
await client.signAndExecuteTransaction({transaction: optionsTx, signer: keypair});

// Step 4: Configure OFT Settings (optional - rate limits)
const rateLimitTx = new Transaction();
await oft.setRateLimitMoveCall(
rateLimitTx,
30184, // Destination EID
false, // Outbound
1000000n, // 1M tokens per window
86400n, // 24 hours
);
await client.signAndExecuteTransaction({transaction: rateLimitTx, signer: keypair});

// Step 4: Configure OFT Settings (optional - fees)
const feeTx = new Transaction();
await oft.setFeeBpsMoveCall(feeTx, 30184, 30); // 0.3% fee
await client.signAndExecuteTransaction({transaction: feeTx, signer: keypair});

// Step 5: Set Peer LAST (required - opens pathway for messaging)
const peerTx = new Transaction();
await oapp.setPeerMoveCall(
peerTx,
30184, // Destination EID (e.g., Base)
Buffer.from('0000000000000000000000006D2e17A05B9Ac62b8499f4bF4757e261005c03A5', 'hex'),
);
await client.signAndExecuteTransaction({transaction: peerTx, signer: keypair});

Configuration order:

  1. Set Libraries (recommended) - Custom send/receive message libraries
  2. Configure DVNs (recommended) - Send and receive verification
  3. Set Enforced Options (optional) - Minimum gas requirements
  4. Configure OFT Settings (optional) - Rate limits, fees
  5. Set Peer (required) - Opens pathway for messaging (call this last!)
tip

For complete DVN configuration details and gas recommendations, see Configuration Guide.

Configuring Remote Chains to Send to IOTA

When configuring OFTs on other chains (e.g., EVM, Solana) to send tokens to IOTA, follow standard LayerZero configuration but note these IOTA-specific requirements:

1. Use Package ID as Peer:

// On EVM: Use IOTA OFT PACKAGE ID (not object ID!)
myOFT.setPeer(
30423, // IOTA L1 mainnet EID
bytes32(0x061a47bf...) // Your IOTA OFT Package ID
);

2. Set Enforced Options for IOTA Destination:

Based on gas profiling, configure appropriate gas limits for IOTA:

// On EVM: Set enforced options for IOTA pathway
import { Options } from "@layerzerolabs/lz-evm-protocol-v2/contracts/messagelib/libs/ExecutorOptions.sol";

bytes memory options = Options.newOptions()
.addExecutorLzReceiveOption(5000, 0); // 5k gas units, no msg.value

myOFT.setEnforcedOptions(
EnforcedOptionParam({
eid: 30423, // IOTA L1 mainnet
msgType: SEND,
options: options
})
);

Gas requirements:

  • IOTA's lz_receive uses 2,000-5,000 units for computation
  • Use 5,000 gas units for safe buffer
  • No msg.value needed (IOTA handles storage internally)

3. Standard DVN Configuration:

DVN configuration on remote chains follows standard LayerZero patterns - no IOTA-specific changes needed. See the platform-specific implementation guides for EVM and Solana configuration.

Example Usage

Sending Tokens (TypeScript SDK)

import {OFT} from '@layerzerolabs/lz-iotal1-oft-sdk-v2';

// Quote the fee
const {nativeFee} = await oft.quote({
dstEid: 30101, // Ethereum
to: recipientBytes32,
amountLD: BigInt(1000000), // 1 token (6 decimals)
options: optionsBytes,
});

// Send tokens
const receipt = await oft.send({
dstEid: 30101,
to: recipientBytes32,
amountLD: BigInt(1000000),
minAmountLD: BigInt(950000), // 5% slippage
nativeFee,
options: optionsBytes,
});

For more SDK usage, see OFT SDK Documentation.

Best Practices & Troubleshooting

Deployment:

  • Use pure LayerZero OFT source without modifications
  • Always wait for transaction finality: await client.waitForTransaction({ digest })
  • Use SDK factory: sdk.getOApp(packageId) (never new OApp(...))
  • Use SDK address exports where available (e.g., PACKAGE_ULN_302_ADDRESS[Stage.MAINNET])
  • For addresses not exported by SDK (e.g., OFTComposerManager), verify against SDK deployment files at @layerzerolabs/lz-iotal1-sdk-v2/deployments/

Security:

  • Test with small amounts before production
  • Validate peer addresses match package IDs (not object IDs)
  • Configure DVNs before setting peers
  • Only deploy one OFT Adapter per token

Common Errors:

  • oapp_registry::get_messaging_channel abort code: 1 → Using object ID instead of package ID as peer
  • InvalidBCSBytes in command 0 → Use OAppUlnConfigBcs.serialize() for DVN config
  • UnusedValueWithoutDrop → Use oft.registerOAppMoveCall() for proper lz_receive_info

For gas profiling and detailed configuration, see Configuration Guide.

Next Steps