Skip to main content
Version: Endpoint V2

LayerZero V2 OFT on Starknet

The Omnichain Fungible Token (OFT) standard enables cross-chain token transfers on Starknet. OFTs extend the OApp pattern with built-in token handling, allowing seamless movement of fungible tokens between chains.

What is an OFT on Starknet?

An OFT on Starknet is a Cairo contract that extends the OApp functionality to enable cross-chain token transfers. It integrates with Starknet's native ERC20 system (token contracts, approvals, and metadata) while providing LayerZero's omnichain capabilities.

This guide will walk you through OFT concepts on Starknet; currently only the OFTMintBurnAdapter is available for deployment. To understand how OFTs integrate with Starknet's ERC20 system and the differences between mint/burn and lock/unlock token management strategies, see Integration with Starknet ERC20 System.

Integration with Starknet ERC20 System

OFT Types

Starknet provides three OFT variants for different use cases:

VariantToken OwnershipUse Case
OFTOFT contract owns the tokenNew tokens native to LayerZero
OFTAdapterWraps existing ERC20Bridge existing tokens (lock/unlock)
OFTMintBurnAdapterDelegates to minter contractExisting tokens with mint/burn permissions
Availability

At the moment, only the OFTMintBurnAdapter is available on Starknet. OFT and OFTAdapter are not yet supported for deployment.

Decision Matrix

Loading diagram...

OFT Mint/Burn Adapter

Use when bridging an existing token where you can grant mint/burn permissions to the adapter.

How It Works

  • Send: Burns tokens via minter contract
  • Receive: Mints tokens via minter contract
  • No Liquidity Required: Mint/burn eliminates liquidity constraints

Additional Features

The OFTMintBurnAdapter includes:

  • Rate Limiting: Control transfer volume per chain
  • Fee Collection: Charge fees on transfers
  • Pausability: Emergency pause functionality
  • Role-Based Access: Granular permission control
  • Upgradeability: Contract upgrade support

OFTMintBurnAdapter class hash: 0x07c02E3797d2c7B848FA94820FfB335617820d2c44D82d6B8Cf71c71fbE7dd6E (View on explorer)

Role Management

The OFTMintBurnAdapter uses OpenZeppelin's AccessControl with the following roles:

Rolefelt252 ValuePermissions
DEFAULT_ADMIN_ROLE0x0Grant/revoke other roles
FEE_MANAGER_ROLE'FEE_MANAGER_ROLE'Withdraw collected fees
PAUSE_MANAGER_ROLE'PAUSE_MANAGER_ROLE'Pause/unpause contract
RATE_LIMITER_MANAGER_ROLE'RATE_LIMITER_MANAGER_ROLE'Configure rate limits
UPGRADE_MANAGER_ROLE'UPGRADE_MANAGER_ROLE'Upgrade contract
Role Constants

Roles are defined as short strings (felt252). To grant a role via sncast, use the string's felt252 encoding. For example, 'FEE_MANAGER_ROLE' encodes to 0x4645455f4d414e414745525f524f4c45.

# Grant FEE_MANAGER_ROLE to an address
sncast --account <ACCOUNT_NAME> invoke \
--contract-address 0x<OFT/OFT_ADAPTER> \
--function grant_role \
--url <RPC_URL> \
--arguments '0x4645455f4d414e414745525f524f4c45, <RECIPIENT_ADDRESS>'

Deployment

Before building an OFT, install the required dependencies.

New to Starknet?

If you haven't used Starknet before, start with Getting Started on Starknet to understand the account model, tooling, and development basics.

Prerequisites:

  • Scarb and Starknet Foundry installed (see Getting Started)
  • Node.js and npm for installing LayerZero packages
  • A funded Starknet account and RPC URL for deployment (see Getting Started)

Deployment workflow for OFTMintBurnAdapter:

  1. Deploy ERC20MintBurnUpgradeable as your token
  2. Deploy OFTMintBurnAdapter with the token address as both erc20_token and minter_burner
  3. Grant the adapter's address permission to mint/burn on the token contract

Step 1: Deploy ERC20MintBurnUpgradeable

For OFTMintBurnAdapter deployments, LayerZero provides a reference ERC20 token implementation with built-in mint/burn permissions:

This contract:

  • Implements the IMintableToken interface
  • Supports role-based access for mint/burn permissions
  • Is upgradeable via OpenZeppelin's UpgradeableComponent

The ERC20MintBurnUpgradeable class has been declared and has been verified - view on explorer.

# Deploy
sncast --account <ACCOUNT_NAME> deploy \
--class-hash 0x01bea3900ebe975f332083d441cac55f807cf5de7b1aa0b7ccbda1de53268500 \
--url <RPC_URL> \
--arguments '"MyToken", "MTK", 18, <DEFAULT_ADMIN_ADDRESS>'

Constructor parameters:

  • name (ByteArray) - Token name (use quoted string)
  • symbol (ByteArray) - Token symbol (use quoted string)
  • decimals (u8) - Token decimals (e.g., 18)
  • default_admin (ContractAddress) - Address granted DEFAULT_ADMIN_ROLE. You can set this to your address.

Running the above successfully would return an output like:

Success: Deployment completed

Contract Address: <CONTRACT_ADDRESS>
Transaction Hash: <TXN_HASH>

To see deployment details, visit:
contract: https://sepolia.starkscan.co/contract/<CONTRACT_ADDRESS>
transaction: https://sepolia.starkscan.co/tx/<TXN_HASH>

Copy the Contract Address and set it aside for use in the next step.

Step 2: Deploy OFTMintBurnAdapter

sncast --account <ACCOUNT_NAME> deploy \
--class-hash 0x07c02E3797d2c7B848FA94820FfB335617820d2c44D82d6B8Cf71c71fbE7dd6E \
--url <RPC_URL> \
--arguments '<ERC20_TOKEN_ADDRESS>, <MINTER_BURNER_ADDRESS>, 0x0316d70a6e0445a58c486215fac8ead48d3db985acde27efca9130da4c675878, <OWNER_ADDRESS>, 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d, <SHARED_DECIMALS>'

Constructor parameters:

  • erc20_token: ERC20 token contract address
  • minter_burner: Minter/burner contract address (use the token address)
  • lz_endpoint: LayerZero Endpoint address (0x0316d70a6e0445a58c486215fac8ead48d3db985acde27efca9130da4c675878 for Sepolia, 0x524e065abff21d225fb7b28f26ec2f48314ace6094bc085f0a7cf1dc2660f68 for Mainnet)
  • owner: Contract owner address (your deployer account)
  • native_token: Fee payment token (STRK token address shown above)
  • shared_decimals: Shared decimals across chains (u8, e.g., 6)
STRK Token Address

The address 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d is the STRK token contract on Starknet (same on both Sepolia testnet and Mainnet). This is used to pay LayerZero messaging fees.

Network addresses

For endpoint IDs and LayerZero contract addresses, see Deployed Contracts.


Deployment for Custom OFT

If you need to build a custom OFT contract (e.g., with additional logic or modifications), follow these steps to set up your project before declaring and deploying. You can use the OFTMintBurnAdapter as a starting

Step 1: Install LayerZero Cairo Contracts

The LayerZero Cairo packages are currently published on NPM.

# Create your project directory
mkdir my-oft-project && cd my-oft-project

# Initialize npm and install LayerZero Starknet packages
npm init -y
npm install @layerzerolabs/protocol-starknet-v2 @layerzerolabs/oft-mint-burn-starknet

Step 2: Copy the contracts

Copy the contracts into your project's directory:

cp -R node_modules/@layerzerolabs/oft-mint-burn-starknet/contracts/oft_mint_burn/. .

Step 3: Modify dependency paths

In the Scarb.toml, remove the parent directory references in the path fields:

- lz_utils = { path = "../../node_modules/@layerzerolabs/protocol-starknet-v2/libs/lz_utils" }
- layerzero = { path = "../../node_modules/@layerzerolabs/protocol-starknet-v2/layerzero" }
+ lz_utils = { path = "node_modules/@layerzerolabs/protocol-starknet-v2/libs/lz_utils" }
+ layerzero = { path = "node_modules/@layerzerolabs/protocol-starknet-v2/layerzero" }

Step 4: Make your customizations

Modify the contract as necessary.

Step 5: Build, Declare, and Deploy

# Build the contract
scarb build

# Declare the contract class
sncast declare --contract-name MyCustomOFT

# Deploy with your constructor arguments
sncast deploy \
--class-hash <YOUR_CLASS_HASH> \
--arguments '<CONSTRUCTOR_ARGS>'

Core Operations

Sending Tokens

Step 1: Quote

// Get fee and receipt estimates
let send_param = SendParam {
dst_eid: 30101, // Ethereum Mainnet
to: recipient_bytes32,
amount_ld: 1000000000000000000_u256, // 1 token
min_amount_ld: 900000000000000000_u256, // 0.9 token minimum
extra_options: build_options(200000),
};

let quote = oft.quote_oft(send_param);
// quote.receipt.amount_sent_ld = actual amount debited
// quote.receipt.amount_received_ld = amount received on destination

Step 2: Get Messaging Fee

let messaging_fee = oft.quote_send(send_param, false);
// messaging_fee.native_fee = STRK/ETH needed for LayerZero

Step 3: Send

// Approve tokens if using OFTAdapter
if oft.approval_required() {
token.approve(oft_address, send_param.amount_ld);
}

// Approve native token for messaging fee
native_token.approve(oft_address, messaging_fee.native_fee);

// Send tokens
let result = oft.send(send_param, messaging_fee, refund_address);
// result.message_receipt.guid = unique message ID
// result.oft_receipt = actual amounts sent/received

Decimal Precision

Token Amounts and u256

All token amounts in Starknet OFT contracts use u256, matching Solidity's uint256 and OpenZeppelin's Cairo ERC20 interface for cross-chain compatibility.

// OFT amounts are always u256
let amount_ld: u256 = 1000000000000000000_u256; // 1 token (18 decimals)
let min_amount_ld: u256 = 900000000000000000_u256; // 0.9 token minimum

Local vs Shared Decimals

OFTs use two decimal representations:

TypeDescriptionTypical Value
Local DecimalsToken decimals on this chain18
Shared DecimalsCommon decimals across all chains6
// Shared decimals = 6 means max precision of 6 decimal places
// A token with 18 local decimals has conversion rate of 10^12

const SHARED_DECIMALS: u8 = 6;
let local_decimals: u8 = 18;
let conversion_rate = 10_u256.pow((local_decimals - SHARED_DECIMALS).into()); // 10^12

Dust Removal

When converting from local to shared decimals, precision is lost ("dust"):

// Sending 1.123456789012345678 tokens (18 decimals)
// Shared representation: 1.123456 (6 decimals)
// Dust lost: 0.000000789012345678

fn _remove_dust(self: @ComponentState, amount_ld: u256) -> u256 {
let conversion_rate = self.OFTCore_decimal_conversion_rate.read();
(amount_ld / conversion_rate) * conversion_rate
}
tip

Always use quote_oft before sending to see the exact amounts after dust removal and fees.


Configuration

After deployment, configure your OFT to enable cross-chain transfers. Use the SDK for DVN/executor config and set peers last.

Critical order

Configure security settings before setting peers. Setting peers opens the pathway.

Endpoint IDs

For endpoint IDs and LayerZero contract addresses, see Deployed Contracts.

SDK Setup

Install the SDK dependencies:

npm install starknet @layerzerolabs/lz-v2-protocol-starknet @layerzerolabs/lz-v2-utilities @layerzerolabs/lz-definitions
npm install -D tsx

Create config.ts (or equivalent) and load your compiled artifact from target/release/*.contract_class.json:

import {readFileSync} from 'node:fs';
import {RpcProvider, Account, Contract} from 'starknet';
import {
getEndpointV2Contract,
getOAppContract,
encodeUlnConfig,
encodeExecutorConfig,
MessageLibConfigType,
} from '@layerzerolabs/lz-v2-protocol-starknet';
import {Options} from '@layerzerolabs/lz-v2-utilities';
import {EndpointId, ChainName, Environment} from '@layerzerolabs/lz-definitions';

async function main() {
const RPC_URL = process.env.RPC_URL!;
const ACCOUNT_ADDRESS = process.env.ACCOUNT_ADDRESS!;
const PRIVATE_KEY = process.env.PRIVATE_KEY!;
const OFT_ADDRESS = process.env.OFT_ADDRESS!;
const OFT_ARTIFACT_PATH = process.env.OFT_ARTIFACT_PATH!;

const compiledArtifact = JSON.parse(readFileSync(OFT_ARTIFACT_PATH, 'utf8'));
const provider = new RpcProvider({nodeUrl: RPC_URL});
const account = new Account({provider, address: ACCOUNT_ADDRESS, signer: PRIVATE_KEY});
const endpoint = await getEndpointV2Contract(ChainName.STARKNET, Environment.TESTNET, provider);
const oapp = await getOAppContract(OFT_ADDRESS, provider);
const oappOptions = new Contract({
abi: compiledArtifact.abi,
address: OFT_ADDRESS,
provider,
}).typedv2(compiledArtifact.abi);

const remoteEid = EndpointId.ETHEREUM_V2_MAINNET;

// Configuration code goes here...
}

main().catch(console.error);

Run the script:

RPC_URL=... \
ACCOUNT_ADDRESS=0x... \
PRIVATE_KEY=0x... \
OFT_ADDRESS=0x... \
OFT_ARTIFACT_PATH=./target/release/my_oft_OFT.contract_class.json \
npx tsx config.ts

Prerequisite: Set Delegate (required if configuring via external account)

Endpoint configuration calls (set_send_library, set_receive_library, set_send_configs, set_receive_configs) require the caller to be the OApp itself or an authorized delegate. If you're configuring from an external account, set a delegate first (owner-only):

const setDelegateCall = oapp.populateTransaction.set_delegate(DELEGATE_ADDRESS);
await account.execute([setDelegateCall]);

Use the address of the account that will submit the endpoint configuration transactions.

Step 1: Set Message Libraries (optional)

Use custom send/receive libraries when defaults are unavailable for your EID.

const setSendLibCall = endpoint.populateTransaction.set_send_library(
OFT_ADDRESS,
remoteEid,
SEND_LIB_ADDRESS,
);
const setReceiveLibCall = endpoint.populateTransaction.set_receive_library(
OFT_ADDRESS,
remoteEid,
RECEIVE_LIB_ADDRESS,
0, // Grace period in blocks
);
await account.execute([setSendLibCall, setReceiveLibCall]);

const sendLibAddress = (await endpoint.get_send_library(OFT_ADDRESS, remoteEid)).lib;
const receiveLibAddress = (await endpoint.get_receive_library(OFT_ADDRESS, remoteEid)).lib;

Configure ULN settings for both send and receive. DVN addresses must be sorted ascending.

const sendUlnConfig = encodeUlnConfig({
confirmations: 15,
has_confirmations: true,
required_dvns: [LAYERZERO_DVN_ADDRESS],
has_required_dvns: true,
optional_dvns: [],
optional_dvn_threshold: 0,
has_optional_dvns: false,
});

const setSendConfigCall = endpoint.populateTransaction.set_send_configs(
OFT_ADDRESS,
sendLibAddress,
[
{
eid: remoteEid,
config_type: MessageLibConfigType.ULN, // 2
config: sendUlnConfig,
},
],
);
await account.execute([setSendConfigCall]);

const receiveUlnConfig = encodeUlnConfig({
confirmations: 15,
has_confirmations: true,
required_dvns: [LAYERZERO_DVN_ADDRESS],
has_required_dvns: true,
optional_dvns: [],
optional_dvn_threshold: 0,
has_optional_dvns: false,
});

const setReceiveConfigCall = endpoint.populateTransaction.set_receive_configs(
OFT_ADDRESS,
receiveLibAddress,
[
{
eid: remoteEid,
config_type: MessageLibConfigType.ULN, // 2
config: receiveUlnConfig,
},
],
);
await account.execute([setReceiveConfigCall]);

Executor settings apply to send direction.

const executorConfig = encodeExecutorConfig({
max_message_size: 10000,
executor: EXECUTOR_ADDRESS,
});

const setExecutorConfigCall = endpoint.populateTransaction.set_send_configs(
OFT_ADDRESS,
sendLibAddress,
[
{
eid: remoteEid,
config_type: MessageLibConfigType.EXECUTOR, // 1
config: executorConfig,
},
],
);
await account.execute([setExecutorConfigCall]);

Step 4: Set Enforced Options (optional)

All OFT variants include the OAppOptionsType3Component for managing execution options. Use it to set minimum gas requirements per destination chain. If getOAppContract does not expose set_enforced_options, load your compiled artifact from target/dev/*.contract_class.json as shown in the SDK setup.

const options = Options.newOptions()
.addExecutorLzReceiveOption(200000, 0) // 200k gas for lz_receive
.toBytes();

const setOptionsCall = oappOptions.populateTransaction.set_enforced_options([
{
eid: remoteEid,
msg_type: 1, // SEND
options,
},
]);
await account.execute([setOptionsCall]);

If you prefer sncast, you can call the entrypoint directly:

# Set enforced options for SEND message type (type 1)
# Options format: 0x0003 (type 3 header) + executor options
sncast --account <ACCOUNT_NAME> invoke \
--contract-address <OFT_ADDRESS> \
--function set_enforced_options \
--url <RPC_URL> \
--arguments 'array![layerzero::oapps::common::oapp_options_type_3::structs::EnforcedOptionParam { eid: <DST_EID>, msg_type: 1, options: <OPTIONS_BYTEARRAY> }]'

For <OPTIONS_BYTEARRAY>, pass a ByteArray expression (see the Starknet Foundry calldata transformation docs). With --arguments, use a raw ByteArray struct literal for arbitrary bytes, e.g. core::byte_array::ByteArray { data: array![0x..., 0x...], pending_word: 0x..., pending_word_len: 0 } (data are 31-byte chunks; pending_word_len is 0-30).

Example: lzReceive gas = 120000, value = 0:

sncast --account <ACCOUNT_NAME> invoke \
--contract-address <OFT_ADDRESS> \
--function set_enforced_options \
--url <RPC_URL> \
--arguments 'array![layerzero::oapps::common::oapp_options_type_3::structs::EnforcedOptionParam { eid: <DST_EID>, msg_type: 1, options: core::byte_array::ByteArray { data: array![], pending_word: 0x0003010011010000000000000000000000000001d4c0, pending_word_len: 22_u8 } }]'

Step 5: Set Peer (required, last)

Set the remote peer after security configuration. EVM addresses must be left-padded to 32 bytes.

const peerBytes32 = {value: BigInt('0x000000000000000000000000' + EVM_OFT_ADDRESS.slice(2))};
const setPeerCall = oapp.populateTransaction.set_peer(remoteEid, peerBytes32);
await account.execute([setPeerCall]);

Configuration order:

  1. Set delegate (required if configuring via external account)
  2. Set message libraries (optional)
  3. Configure DVNs (recommended)
  4. Configure executor (recommended)
  5. Set enforced options (optional)
  6. Set peer (required, last)

See the Configuration Guide for detailed options encoding, DVN ordering, and gas recommendations.


Events

OFT-Specific Events

// Tokens sent to another chain
#[derive(Drop, starknet::Event)]
pub struct OFTSent {
#[key]
pub guid: Bytes32,
pub dst_eid: u32,
pub from: ContractAddress,
pub amount_sent_ld: u256,
pub amount_received_ld: u256,
}

// Tokens received from another chain
#[derive(Drop, starknet::Event)]
pub struct OFTReceived {
#[key]
pub guid: Bytes32,
pub src_eid: u32,
pub to: ContractAddress,
pub amount_received_ld: u256,
}

Best Practices & Deployment Checklist

  1. Install dependencies - npm install LayerZero packages
  2. Choose OFT variant based on your token situation
  3. Build contract via scarb build
  4. Declare contract via sncast declare
  5. Deploy contract via sncast deploy with --arguments
  6. Verify contract via sncast verify using Voyager or Walnut
  7. Configure DVNs and executor for security (see Configuration Guide)
  8. Set enforced options for minimum gas
  9. Set peers last on both chains (bidirectional)
  10. Test on testnet before mainnet deployment

Next Steps