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:
| Variant | Token Ownership | Use Case |
|---|---|---|
| OFT | OFT contract owns the token | New tokens native to LayerZero |
| OFTAdapter | Wraps existing ERC20 | Bridge existing tokens (lock/unlock) |
| OFTMintBurnAdapter | Delegates to minter contract | Existing tokens with mint/burn permissions |
At the moment, only the OFTMintBurnAdapter is available on Starknet. OFT and OFTAdapter are not yet supported for deployment.
Decision Matrix
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:
| Role | felt252 Value | Permissions |
|---|---|---|
DEFAULT_ADMIN_ROLE | 0x0 | Grant/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 |
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.
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:
- Deploy
ERC20MintBurnUpgradeableas your token - Deploy
OFTMintBurnAdapterwith the token address as botherc20_tokenandminter_burner - 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
IMintableTokeninterface - 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 grantedDEFAULT_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 addressminter_burner: Minter/burner contract address (use the token address)lz_endpoint: LayerZero Endpoint address (0x0316d70a6e0445a58c486215fac8ead48d3db985acde27efca9130da4c675878for Sepolia,0x524e065abff21d225fb7b28f26ec2f48314ace6094bc085f0a7cf1dc2660f68for 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)
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.
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:
| Type | Description | Typical Value |
|---|---|---|
| Local Decimals | Token decimals on this chain | 18 |
| Shared Decimals | Common decimals across all chains | 6 |
// 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
}
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.
Configure security settings before setting peers. Setting peers opens the pathway.
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;
Step 2: Configure DVNs (recommended)
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]);
Step 3: Configure Executor (recommended)
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:
- Set delegate (required if configuring via external account)
- Set message libraries (optional)
- Configure DVNs (recommended)
- Configure executor (recommended)
- Set enforced options (optional)
- 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
- Install dependencies - npm install LayerZero packages
- Choose OFT variant based on your token situation
- Build contract via
scarb build - Declare contract via
sncast declare - Deploy contract via
sncast deploywith--arguments - Verify contract via
sncast verifyusing Voyager or Walnut - Configure DVNs and executor for security (see Configuration Guide)
- Set enforced options for minimum gas
- Set peers last on both chains (bidirectional)
- Test on testnet before mainnet deployment
Next Steps
- Configuration Guide - DVN and security setup
- Protocol Overview - Message lifecycle
- Technical Reference - Deployment tooling
- Troubleshooting - Common errors