Skip to main content
Version: Endpoint V2 Docs

LayerZero V2 Solana OFT Native Account

caution

The Solana OFT, Endpoint, and ULN Programs are currently in Mainnet Beta!


The Omnichain Fungible Token (OFT) Standard allows fungible tokens to be transferred across multiple blockchains without asset wrapping or middlechains.

This standard works by burning tokens on the source chain whenever an omnichain transfer is initiated, sending a message via the LayerZero protocol and delivering a function call to the destination contract to mint the same number of tokens burned, creating a unified supply across all networks LayerZero supports.

OFT Example OFT Example

Using this design pattern, LayerZero can extend any fungible token to interoperate with other chains. The Solana equivalent of this standard is the OFT Program.

tip

You should be familiar with the Solana Program Library and the Token 2022 program before continuing.

Installation

To start using the LayerZero OFT Program, you can install the OFT package to an existing project:

npm install @layerzerolabs/lz-solana-sdk-v2

Deploying a Solana OFT

The OFT Program interacts with the Solana Token Program to allow new or existing Fungible Tokens on Solana to transfer balances between different chains. It achieves this using Program Derived Addresses (PDAs) that are derived from the address of the OFT Program's Mint Accounts.

If you’re not familiar with Solana’s Token Program, Mint Accounts are responsible for storing the global information of a Token derived from a specific program, and Token Accounts store the relationship between a wallet and that specific Mint Account.

LayerZero's OFT Standard introduces the OFT Config Account, a Program Derived Address (PDA) responsible for mapping your Token's specific LayerZero configuration.

Solana Token Program Solana Token Program

caution

You will need to deploy your own OFT Program to start bridging your SPL Tokens.

OFT Account Model

Before creating a new OFT, you should first understand the Solana Account Model for the OFT Standard on Solana.

The Solana OFT uses 5 main accounts:

Account NameExecutableDescription
OFT ProgramtrueThe OFT Program itself, the executable, stateless code which controls how OFTs interact with the LayerZero Endpoint and the SPL Token.
SPL Token ProgramtrueThe SPL Token Program itself, the executable, stateless code which controls the token interface for Solana.
SPL Mint AccountfalseEffectively a global counter for a specific token, stores data such as total supply, decimal precision, mint authority, and freeze authority.
OFT Config AccountfalseStores data about each OFT such as the underlying SPL Token Mint, the SPL Token Program, Endpoint Program, admin, and extensions which hold either an escrow account for lockbox (OFT Adapter), or the Mint Authority for the SPL Token (OFT).
Associated Token AccountfalseA program-derived account consisting of the wallet address and the SPL Token Mint.

All of your specific configurations will be controlled by the OFT Config Account, a Program Derived Address (PDA) created based on whether you initialized an OFT or OFT Adapter (continue reading to determine which makes sense to deploy):

An OFT Config initialized using the createInitNativeOftIx can be derived by:

// Define the OFT Program ID that you deployed
const OFT_PROGRAM_ID = new PublicKey('YOUR_OFT_PROGRAM_ID');

// Find the OFT Config PDA using your SPL Token Mint keypair
const [oftConfig] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), mintKeypair.publicKey.toBuffer()],
OFT_PROGRAM_ID,
);

createInitNativeOftIx allows you to convert your newly minted SPL Token to the OFT Standard by transferring its mint authority to the OFT Config. This OFT Config then assumes control over the token mint’s functionalities, specifically to:

  • _debit (Burning Tokens): The OFT Config can burn tokens when a user transfers out of Solana to ensure that the supply on the original chain is accurately reflected.

  • _credit (Minting Tokens): Conversely, the OFT Config can mint when tokens are being transferred into Solana, allowing the OFT Config to increase the supply to match incoming transfers from other chains.

caution

Once the SPL Token Mint Authority has been transferred, it cannot be removed from the OFT Config without upgrading the OFT Program.

Creating a New Native OFT

SOL is the 'native gas token' of Solana. All other tokens (fungible and non-fungible tokens (NFTs)), are called Solana Program Library Tokens.

You will use the Solana Token Program to create a new fungible token on Solana, before initializing that token with the LayerZero OFT Program. You should understand how to add your own Token Metadata before deploying your SPL Token as an OFT.

info

The examples below show how to integrate these scripts as Hardhat tasks, which is a common setup for many developers working with both EVM and non-EVM smart contracts in the same project.

tip

In the examples below, each Solana transaction sets a custom compute unit price using the getRecentPrioritizationFee Solana RPC method. Read more here on implementing a custom getFee to ensure your transactions land.

Using @metaplex-foundation/umi and the @layerzerolabs/lz-solana-sdk-v2:

// Import necessary functions and classes from Solana SDKs
import {env} from 'process';

import {bs58} from '@coral-xyz/anchor/dist/cjs/utils/bytes';
import {TokenStandard, createAndMint} from '@metaplex-foundation/mpl-token-metadata';
import {
AuthorityType,
findAssociatedTokenPda,
mplToolbox,
setAuthority,
setComputeUnitPrice,
} from '@metaplex-foundation/mpl-toolbox';
import {
TransactionBuilder,
createSignerFromKeypair,
generateSigner,
percentAmount,
signerIdentity,
} from '@metaplex-foundation/umi';
import {createUmi} from '@metaplex-foundation/umi-bundle-defaults';
import {
fromWeb3JsInstruction,
fromWeb3JsPublicKey,
toWeb3JsKeypair,
} from '@metaplex-foundation/umi-web3js-adapters';
import {TOKEN_PROGRAM_ID} from '@solana/spl-token';
import {PublicKey, clusterApiUrl} from '@solana/web3.js';
import {getExplorerLink} from '@solana-developers/helpers';
import {task} from 'hardhat/config';
import {TaskArguments} from 'hardhat/types';

import {OFT_SEED, OftTools} from '@layerzerolabs/lz-solana-sdk-v2';

import getFee from '../utils/getFee';

task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Config account')
.addParam('program', 'The OFT Program id')
.addParam('staging', 'Solana mainnet or testnet')
.addOptionalParam('amount', 'The initial supply to mint on solana')
.setAction(async (taskArgs: TaskArguments) => {
// 1. Setup UMI environment using environment variables (private key and Solana RPC)

// Determine RPC URL based on network staging (mainnet or testnet)
const RPC_URL_SOLANA =
taskArgs.staging === 'mainnet'
? env.RPC_URL_SOLANA?.toString() ?? clusterApiUrl('mainnet-beta')
: env.RPC_URL_SOLANA_TESTNET?.toString() ?? clusterApiUrl('devnet');

// Initialize UMI with the Solana RPC URL and necessary tools
const umi = createUmi(RPC_URL_SOLANA).use(mplToolbox());

// Generate a wallet keypair from the private key stored in the environment
const umiWalletKeyPair = umi.eddsa.createKeypairFromSecretKey(
bs58.decode(env.SOLANA_PRIVATE_KEY!),
);

// Convert the UMI keypair to a format compatible with web3.js
// This is necessary as the @layerzerolabs/lz-solana-sdk-v2 library uses web3.js keypairs
const web3WalletKeyPair = toWeb3JsKeypair(umiWalletKeyPair);

// Create a signer object for UMI to use in transactions
const umiWalletSigner = createSignerFromKeypair(umi, umiWalletKeyPair);

// Set the UMI environment to use the signer identity
umi.use(signerIdentity(umiWalletSigner));

// Define the OFT Program ID based on the task arguments
const OFT_PROGRAM_ID = new PublicKey(taskArgs.program);

// Number of decimals for the token (recommended value for SHARED_DECIMALS is 6)
const LOCAL_DECIMALS = 9;

// Define the number of shared decimals for the token
// The OFT Standard handles differences in decimal precision before every cross-chain
// by "cleaning" the dust off of the amount to send by dividing by a `decimalConversionRate`.
// For example, when you send a value of 123456789012345678 (18 decimals):
//
// decimalConversionRate = 10^(localDecimals − sharedDecimals) = 10^(18−6) = 10^12
// 123456789012345678 / 10^12 = 123456.789012345678 = 123456
// amount = 123456 * 10^12 = 12345600000000000
//
// For more information, see the OFT Standard documentation:
// https://docs.layerzero.network/v2/developers/solana/oft/native#token-transfer-precision
const SHARED_DECIMALS = 6;

// 2. Generate the accounts we want to create (SPL Token)

// Generate a new keypair for the SPL token mint account
const token = generateSigner(umi);

// Convert the UMI keypair to web3.js compatible keypair
const web3TokenKeyPair = toWeb3JsKeypair(token);

// Get the average compute unit price
// getFee() uses connection.getRecentPrioritizationFees() to get recent fees and averages them
const {averageFeeExcludingZeros} = await getFee();
const computeUnitPrice = BigInt(Math.round(averageFeeExcludingZeros));

// Create and mint the SPL token using UMI
const createTokenTx = await createAndMint(umi, {
mint: token, // New token account
name: 'OFT', // Token name
symbol: 'OFT', // Token symbol
isMutable: true, // Allow token metadata to be mutable
decimals: LOCAL_DECIMALS, // Number of decimals for the token
uri: '', // URI for token metadata
sellerFeeBasisPoints: percentAmount(0), // Fee percentage
authority: umiWalletSigner, // Authority for the token mint
amount: taskArgs.amount, // Initial amount to mint
tokenOwner: umiWalletSigner.publicKey, // Owner of the token
tokenStandard: TokenStandard.Fungible, // Token type (Fungible)
})
.add(setComputeUnitPrice(umi, {microLamports: computeUnitPrice * BigInt(2)}))
.sendAndConfirm(umi);

// Log the transaction and token mint details
const createTokenTransactionSignature = bs58.encode(createTokenTx.signature);
const createTokenLink = getExplorerLink(
'tx',
createTokenTransactionSignature.toString(),
'mainnet-beta',
);
console.log(`✅ Token Mint Complete! View the transaction here: ${createTokenLink}`);

// Find the associated token account using the generated token mint
const tokenAccount = findAssociatedTokenPda(umi, {
mint: token.publicKey,
owner: umiWalletSigner.publicKey,
});

console.log(`Your token account is: ${tokenAccount[0]}`);

// 3. Derive OFT Config from those accounts and program ID

// Derive the OFT Config public key from the token mint keypair and OFT Program ID
const [oftConfig] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), web3TokenKeyPair.publicKey.toBuffer()],
OFT_PROGRAM_ID,
);

console.log(`OFT Config:`, oftConfig);

// 4. Create new account (OFT Config)

// Create a new transaction to transfer mint authority to the OFT Config account and initialize a new native OFT
const setAuthorityTx = await setAuthority(umi, {
owned: token.publicKey, // SPL Token mint account
owner: umiWalletKeyPair.publicKey, // Current authority of the token mint
authorityType: AuthorityType.MintTokens, // Authority type to transfer
newAuthority: fromWeb3JsPublicKey(oftConfig), // New authority (OFT Config)
});

// Initialize the OFT using the OFT Config and the token mint
const oftConfigMintIx = await OftTools.createInitNativeOftIx(
web3WalletKeyPair.publicKey, // Payer
web3WalletKeyPair.publicKey, // Admin
web3TokenKeyPair.publicKey, // Mint account
web3WalletKeyPair.publicKey, // OFT Mint Authority
SHARED_DECIMALS, // OFT Shared Decimals
TOKEN_PROGRAM_ID, // Token Program ID
OFT_PROGRAM_ID, // OFT Program ID
);

// Convert the instruction to UMI format
const convertedInstruction = fromWeb3JsInstruction(oftConfigMintIx);

// Build the transaction with the OFT Config initialization instruction
const configBuilder = new TransactionBuilder([
{
instruction: convertedInstruction,
signers: [umiWalletSigner],
bytesCreatedOnChain: 0,
},
]);

// Set the fee payer and send the transaction
const oftConfigTransaction = await setAuthorityTx
.add(configBuilder)
.add(setComputeUnitPrice(umi, {microLamports: computeUnitPrice * BigInt(4)}))
.sendAndConfirm(umi);

// Log the transaction details
const oftConfigSignature = bs58.encode(oftConfigTransaction.signature);
const oftConfigLink = getExplorerLink('tx', oftConfigSignature.toString(), 'mainnet-beta');
console.log(`✅ You created an OFT, view the transaction here: ${oftConfigLink}`);
});

You should only mint additional tokens if this is your first canonical OFT supply (i.e., the first source of tokens across every chain). While OFT <-> OFT connections will handle additional token supplies without an issue, OFT <-> OFT Adapter connections will need to ensure that the total supply globally is equal to the lockbox supply in OFT Adapter.

caution

Once you set the OFT Mint Authority to null, you cannot change the OFT Mint Authority again. This enforces that tokens can only be minted when receiving a cross-chain transfer.

In certain edge cases, for example if a receiver's address is blacklisted, there will be no way to recover the funds on Solana, without either upgrading the OFT Program itself, or recovering the funds on the source chain using an exposed mint method.

info

The SPL Token Mint Authority and OFT Mint Authority are separate accounts. When initializing your OFT, you transfer the SPL Token Mint Authority to your OFT Config PDA. The OFT Mint Authority controls the passthrough function mint_to, which allows the set mint authority of the OFT Config Account to create new tokens by calling the underlying SPL Token's mint method.

Adapting an Existing SPL Token

The createInitAdapterOftIx creates an OFT Config that acts as a "lockbox", enabling existing Solana Token Mints that cannot transfer the mint authority to add cross-chain functionality.

See Solana OFT Adapter for more info on enabling cross-chain transfers for existing tokens.

// Import necessary functions and classes from Solana SDKs
import {env} from 'process';

import {bs58} from '@coral-xyz/anchor/dist/cjs/utils/bytes';
import {TokenStandard, createAndMint} from '@metaplex-foundation/mpl-token-metadata';
import {
findAssociatedTokenPda,
mplToolbox,
setComputeUnitPrice,
} from '@metaplex-foundation/mpl-toolbox';
import {
TransactionBuilder,
createSignerFromKeypair,
generateSigner,
percentAmount,
signerIdentity,
} from '@metaplex-foundation/umi';
import {createUmi} from '@metaplex-foundation/umi-bundle-defaults';
import {fromWeb3JsInstruction, toWeb3JsKeypair} from '@metaplex-foundation/umi-web3js-adapters';
import {TOKEN_PROGRAM_ID} from '@solana/spl-token';
import {PublicKey, clusterApiUrl} from '@solana/web3.js';
import {getExplorerLink} from '@solana-developers/helpers';
import {task} from 'hardhat/config';
import {TaskArguments} from 'hardhat/types';

import {OFT_SEED, OftTools} from '@layerzerolabs/lz-solana-sdk-v2';

import getFee from '../utils/getFee';

task(
'lz:oft-adapter:solana:create',
'Mints new SPL Token, Lockbox, and new OFT Adapter Config account',
)
.addParam('program', 'The OFT Program id')
.addParam('staging', 'Solana mainnet or testnet')
.addOptionalParam('amount', 'The initial supply to mint on solana')
.setAction(async (taskArgs: TaskArguments) => {
// 1. Setup UMI environment using environment variables (private key and Solana RPC)

// Determine RPC URL based on network staging (mainnet or testnet)
const RPC_URL_SOLANA =
taskArgs.staging === 'mainnet'
? env.RPC_URL_SOLANA?.toString() ?? clusterApiUrl('mainnet-beta')
: env.RPC_URL_SOLANA_TESTNET?.toString() ?? clusterApiUrl('devnet');

// Initialize UMI with the Solana RPC URL and necessary tools
const umi = createUmi(RPC_URL_SOLANA).use(mplToolbox());

// Generate a wallet keypair from the private key stored in environment
const umiWalletKeyPair = umi.eddsa.createKeypairFromSecretKey(
bs58.decode(env.SOLANA_PRIVATE_KEY!),
);

// Convert the UMI keypair to a format compatible with web3.js
// This is necessary as the @layerzerolabs/lz-solana-sdk-v2 library uses web3.js keypairs
const web3WalletKeyPair = toWeb3JsKeypair(umiWalletKeyPair);

// Create a signer object for UMI to use in transactions
const umiWalletSigner = createSignerFromKeypair(umi, umiWalletKeyPair);

// Set the UMI environment to use the signer identity
umi.use(signerIdentity(umiWalletSigner));

// Define the OFT Program ID based on the task arguments
const OFT_PROGRAM_ID = new PublicKey(taskArgs.program);

// Define the number of decimals for the token
const LOCAL_DECIMALS = 9;

// Define the number of shared decimals for the token
// The OFT Standard handles differences in decimal precision before every cross-chain
// by "cleaning" the dust off of the amount to send by dividing by a `decimalConversionRate`.
// For example, when you send a value of 123456789012345678 (18 decimals):
//
// decimalConversionRate = 10^(localDecimals − sharedDecimals) = 10^(18−6) = 10^12
// 123456789012345678 / 10^12 = 123456.789012345678 = 123456
// amount = 123456 * 10^12 = 12345600000000000
//
// For more information, see the OFT Standard documentation:
// https://docs.layerzero.network/v2/developers/solana/oft/native#token-transfer-precision
const SHARED_DECIMALS = 6;

// 2. Generate the accounts we want to create (SPL Token / Lockbox)

// Generate a new keypair for the SPL token mint account
const token = generateSigner(umi);

// Generate a new keypair for the Lockbox account
const lockbox = generateSigner(umi);

// Convert the UMI keypairs to web3.js compatible keypairs
const web3TokenKeyPair = toWeb3JsKeypair(token);
const web3LockboxKeypair = toWeb3JsKeypair(lockbox);

// Get the average compute unit price
// getFee() uses connection.getRecentPrioritizationFees() to get recent fees and averages them
// This is necessary as Solana's default compute unit price is not always sufficient to land the tx
const {averageFeeExcludingZeros} = await getFee();
const computeUnitPrice = BigInt(Math.round(averageFeeExcludingZeros));

// Create and mint the SPL token using UMI
const createTokenTx = await createAndMint(umi, {
mint: token, // New token account
name: 'OFT', // Token name
symbol: 'OFT', // Token symbol
isMutable: true, // Allow token metadata to be mutable
decimals: LOCAL_DECIMALS, // Number of decimals for the token
uri: '', // URI for token metadata
sellerFeeBasisPoints: percentAmount(0), // Fee percentage
authority: umiWalletSigner, // Authority for the token mint
amount: taskArgs.amount, // Initial amount to mint
tokenOwner: umiWalletSigner.publicKey, // Owner of the token
tokenStandard: TokenStandard.Fungible, // Token type (Fungible)
})
.add(setComputeUnitPrice(umi, {microLamports: computeUnitPrice * BigInt(2)}))
.sendAndConfirm(umi);

// Log the transaction and token mint details
console.log(
`✅ Token Mint Complete! View the transaction here: ${getExplorerLink(
'tx',
bs58.encode(createTokenTx.signature),
taskArgs.staging,
)}`,
);

// Find the associated token account using the generated token mint
const tokenAccount = findAssociatedTokenPda(umi, {
mint: token.publicKey,
owner: umiWalletSigner.publicKey,
});

console.log(`Your token account is: ${tokenAccount[0]}`);

// 3. Derive OFT Config from those accounts and program ID

// Derive the OFT Config public key from the Lockbox keypair and OFT Program ID
const [oftConfig] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), web3LockboxKeypair.publicKey.toBuffer()],
OFT_PROGRAM_ID,
);

console.log(`OFT Config:`, oftConfig);

// 4. Create new account (OFT Adapter Config)

// Create the OFT Adapter Config initialization instruction
const adapterIx = await OftTools.createInitAdapterOftIx(
web3WalletKeyPair.publicKey, // Payer
web3WalletKeyPair.publicKey, // Admin
web3TokenKeyPair.publicKey, // SPL Token Mint Account
web3LockboxKeypair.publicKey, // Lockbox account
SHARED_DECIMALS, // Number of shared decimals
TOKEN_PROGRAM_ID, // Token program ID
OFT_PROGRAM_ID, // OFT Program ID
);

// Build and send the transaction with the create OFT Adapter instruction
const oftConfigTransaction = await new TransactionBuilder([
{
instruction: fromWeb3JsInstruction(adapterIx),
signers: [umiWalletSigner],
bytesCreatedOnChain: 0,
},
])
.add(setComputeUnitPrice(umi, {microLamports: computeUnitPrice * BigInt(4)}))
.sendAndConfirm(umi);

// Log the transaction details
console.log(
`✅ You created an OFT Adapter, view the transaction here: ${getExplorerLink(
'tx',
bs58.encode(oftConfigTransaction.signature),
taskArgs.staging,
)}`,
);
});

After creating your oftConfig with the createInitAdapterOftIx instruction, return to this page to continue OFT setup.

Optional: Setting New Delegate

During OFT Initialization, you will set an address as a delegate. This address will have the ability to implement custom configurations such as setting DVNs, Executors, and also message debugging functions such as skipping inbound packets.

By default, the contract owner should be set as the delegate, but if you want to set a new address, you can use createSetDelegateIx.

Initializing Config Accounts

// Import necessary tools from the LayerZero Solana SDK
import {OftTools} from '@layerzerolabs/lz-solana-sdk-v2';

// Set up a connection to the Solana RPC
const rpcConnection = new Connection(RPC_URL_SOLANA, 'confirmed');

// Retrieve the wallet keypair from environment variables
const walletKeyPair = getKeypairFromEnvironment('SOLANA_PRIVATE_KEY');

// Specify the mint public key for the token
const mintPublicKey = new PublicKey('YOUR_MINT_PUBLIC_KEY');

// Specify the OFT program ID on Solana
const OFT_PROGRAM_ID = new PublicKey('YOUR_OFT_PROGRAM_ID');

// Specify the LayerZero ULN program ID on Solana
const ULN_PROGRAM_ID = new PublicKey('ULN_PROGRAM_ID');

// Derive the OFT Config's Program Derived Address (PDA) using the mint public key and a seed
const [oftConfig] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), mintPublicKey.toBuffer()],
OFT_PROGRAM_ID,
);

// Placeholder 'peer' object array for mapping destination endpoint IDs (dstEid) and peer addresses
// Replace with your actual dstEid's and peerAddresses
const peers = [
{dstEid: 30101, peerAddress: addressToBytes32('0x0000000000000000000000000000000000000001')},
{dstEid: 30102, peerAddress: addressToBytes32('0x0000000000000000000000000000000000000002')},
// Add more peers as needed
];

// Loop through each peer to initialize configuration for each pathway
for (const peer of peers) {
// Build the transaction to initialize the peer account for the specified dstEid
const peerTransaction = new Transaction().add(
await OftTools.createInitNonceIx(
walletKeyPair.publicKey, // Wallet public key
peer.dstEid, // Destination Endpoint ID (dstEid)
oftConfig, // Derived OFT Config PDA
peer.peerAddress, // Peer EVM address converted to bytes32
),
);

// Send and confirm the peer initialization transaction
const peerSignature = await sendAndConfirmTransaction(
rpcConnection,
peerTransaction,
[walletKeyPair],
{commitment: `confirmed`},
);

// Log the transaction link for peer initialization
const link = getExplorerLink('tx', peerSignature, taskArgs.network);
console.log(
`✅ You initialized the peer account for dstEid ${peer.dstEid}! View the transaction here: ${link}`,
);

// Build the transaction to initialize the send library for the pathway
const initSendLibraryTransaction = new Transaction().add(
await OftTools.createInitSendLibraryIx(walletKeyPair.publicKey, oftConfig, peer.dstEid),
);

// Send and confirm the send library initialization transaction
const initSendLibrarySignature = await sendAndConfirmTransaction(
rpcConnection,
initSendLibraryTransaction,
[walletKeyPair],
{commitment: `confirmed`},
);
console.log(
`✅ You initialized the send library for dstEid ${peer.dstEid}! View the transaction here: ${initSendLibrarySignature}`,
);

// Build the transaction to initialize the receive library for the pathway
const initReceiveLibraryTransaction = new Transaction().add(
await OftTools.createInitReceiveLibraryIx(walletKeyPair.publicKey, oftConfig, peer.dstEid),
);

// Send and confirm the receive library initialization transaction
const initReceiveLibrarySignature = await sendAndConfirmTransaction(
rpcConnection,
initReceiveLibraryTransaction,
[walletKeyPair],
{commitment: `confirmed`},
);
console.log(
`✅ You initialized the receive library for dstEid ${peer.dstEid}! View the transaction here: ${initReceiveLibrarySignature}`,
);

// Build the transaction to initialize the OFT Config Account for the pathway
const initConfigTransaction = new Transaction().add(
await OftTools.createInitConfigIx(
walletKeyPair.publicKey, // Wallet public key
oftConfig, // Derived OFT Config PDA
peer.dstEid, // Destination Endpoint ID (dstEid)
ULN_PROGRAM_ID, // ULN Program ID
),
);

// Send and confirm the config initialization transaction
const initConfigSignature = await sendAndConfirmTransaction(
rpcConnection,
initConfigTransaction,
[walletKeyPair],
{commitment: `confirmed`},
);
console.log(
`✅ You initialized the config for dstEid ${peer.dstEid}! View the transaction here: ${initConfigSignature}`,
);
}

Setting Trusted Peers

Now that you have deployed your Solana token, you will need to connect the OFT Instance to your other chains.

tip

This guide assumes you already have deployed other OFT Instances on your desired EVM or other non-EVM chains. If you have not deployed any other OFT contracts yet, see the OFT Quickstart in the EVM section.


While LayerZero provides default configuration settings for most pathways, you should only connect your OFT Instances on different chains after viewing your DVN and Executor Configuration Settings.

Once you've finished configuring your OFT, you can connect your OFT deployment to different chains by using the createSetPeerIx.

The function takes 2 arguments: dstEid, the endpoint ID for the destination chain that the other OFT contract lives on, and peer, the destination OFT's contract address in bytes32 format.

Create an empty file called set-peer.ts. After loading keypairs, and your OFT Config, you will call createSetPeerIx for each pairwise connection.

See the current list of Deployed Endpoint IDs in the EVM section.

// Import necessary tools from the LayerZero Solana SDK
import {OftTools} from '@layerzerolabs/lz-solana-sdk-v2';

// Optional: Import utilities for address conversion
import {addressToBytes32} from '@layerzerolabs/lz-v2-utilities';

// Set up a connection to the Solana RPC
const rpcConnection = new Connection(RPC_URL_SOLANA, 'confirmed');

// Retrieve the wallet keypair from environment variables
const walletKeyPair = getKeypairFromEnvironment('SOLANA_PRIVATE_KEY');

// Specify the mint public key for the token
const mintPublicKey = new PublicKey('YOUR_MINT_PUBLIC_KEY');

// Specify the OFT program ID on Solana
const OFT_PROGRAM_ID = new PublicKey('YOUR_OFT_PROGRAM_ID');

// Derive the OFT Config's Program Derived Address (PDA) using the mint public key and a seed
const [oftConfig] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), mintPublicKey.toBuffer()],
OFT_PROGRAM_ID,
);

// Placeholder 'peer' object array for mapping destination endpoint IDs (dstEid) and peer addresses
// Before setting the peer, convert the EVM peer addresses to bytes32 by left zero-padding the address
// Replace with your actual dstEid's and peerAddresses
const peers = [
{dstEid: 30101, peerAddress: addressToBytes32('0x0000000000000000000000000000000000000001')},
{dstEid: 30102, peerAddress: addressToBytes32('0x0000000000000000000000000000000000000002')},
// Add more peers as needed
];

// Loop through each peer to initialize and set configuration for each pathway
for (const peer of peers) {
// Build the transaction to set the peer account for the specified dstEid
const peerTransaction = new Transaction().add(
await OftTools.createSetPeerIx(
walletKeyPair.publicKey, // Admin public key
oftConfig, // Derived OFT Config PDA
peer.dstEid, // Destination Endpoint ID (dstEid)
peer.peerAddress, // Peer EVM address converted to bytes32
),
);

// Send and confirm the peer setting transaction
const peerSignature = await sendAndConfirmTransaction(
rpcConnection,
peerTransaction,
[walletKeyPair],
{commitment: `confirmed`},
);

// Log the transaction link for setting the peer
const setLink = getExplorerLink('tx', peerSignature, 'testnet');
console.log(
`✅ You set ${await getPeerAddress(rpcConnection, oftConfig, peer.dstEid)} for dstEid ${
peer.dstEid
}! View the transaction here: ${setLink}`,
);
}

When this OFT receives messages, the LayerZero Endpoint will compare the OFT's set peer against the inbound message's sender, and reject the message if the sender's address does not match the peer.

caution

setPeer opens your OFT to start receiving messages from the address set, meaning you should configure any application settings you intend on changing prior to calling setPeer.


danger

OFTs need setPeer to be called correctly on both Chain A and Chain B to send and receive messages. The peer address uses bytes32 for handling non-EVM destination chains.

If the peer has been set to an incorrect destination address, your messages will not be delivered and handled properly. If not resolved, users can burn source funds without a corresponding mint on destination. You can confirm the peer address is the expected destination OFT address by using the isPeer function.


Message Execution Options

_options are a generated bytes array with specific instructions for the DVNs and Executor to when handling cross-chain messages.

You can find how to generate all the available _options in Message Execution Options, but for this tutorial you should focus primarily on using @layerzerolabs/lz-v2-utilities, specifically the Options class.

Setting Enforced Options Outbound to EVM

An enforced options sets a minimum amount of gas that will always be charged to users calling send, and delivered to a destination chain per message. In the case of OFT, you mostly want to make sure you set enough gas_limit for delivering the message and transferring the ERC20 token to the receiver, plus any additional logic.

A typical OFT's lzReceive call and mint will use 60000 gas on most EVM chains, so you can enforce this option to require callers to pay a 60000 gas limit in the source chain transaction to prevent out of gas issues on destination:

Options.newOptions().addExecutorLzReceiveOption(gas_limit, msg.value);
// Add the createSetEnforcedOptionsIx to your imports
import {OftTools} from '@layerzerolabs/lz-solana-sdk-v2';

// Optional: Add the LayerZero utilities for building options
import {Options} from '@layerzerolabs/lz-v2-utilities';

// ... OFT mint initialization

// You can reuse your peers mapping to set options for each dstEid
const peers = [
{dstEid: 30101, peerAddress: addressToBytes32('0x0000000000000000000000000000000000000001')},
{dstEid: 30102, peerAddress: addressToBytes32('0x0000000000000000000000000000000000000002')},
// ...
];

// In this example you will set a static execution gas _option for every chain.
// In practice, you will likely want to profile each of your gas settings on the destination chain,
// and set a custom gas amount depending on the unique gas needs of each destination chain.
for (const peer of peers) {
const optionTransaction = new Transaction().add(
await OftTools.createSetEnforcedOptionsIx(
user.publicKey, // your admin address
oftConfig, // your OFT Config
peer.dstEid, // destination endpoint id for the options to apply to
Options.newOptions().addExecutorLzReceiveOption(65000, 0).toBytes(), // send options
Options.newOptions()
.addExecutorLzReceiveOption(65000, 0)
.addExecutorComposeOption(0, 50000, 0)
.toBytes(), // sendAndCall options
),
);

// Send the setEnforcedOptions transaction
const optionSignature = await sendAndConfirmTransaction(connection, optionTransaction, [user]);
const link = getExplorerLink('tx', optionSignature, 'testnet');
console.log(`✅ You set options for dstEid ${peer.dstEid}! View the transaction here: ${link}`);
}

You can see all the available options settings in Solana Execution Options.

caution

Your enforcedOptions will always be charged to a user when calling send. Any extraOptions passed in the send call will be charged on top of the enforced settings.

Passing identical _options in both enforcedOptions and extraOptions will charge the caller twice on the source chain, because LayerZero interprets duplicate _options as two separate requests for gas.

tip

ExecutorLzReceiveOption specifies a quote paid in advance on the source chain by the msg.sender for the equivalent amount of native gas to be used on the destination chain. If the actual cost to execute the message is less than what was set in _options, there is no default way to refund the sender the difference. Application developers need to thoroughly profile and test gas amounts to ensure consumed gas amounts are correct and not excessive.

Setting Enforced Options Inbound to Solana

On your equivalent EVM implementation of the OFT, you must send at minimum 0.0015 SOL (1500000 lamports) in your lzReceiveOption when sending to Solana.

Options.newOptions().addExecutorLzReceiveOption(compute_units, lamports);
caution

Unlike EVM addresses, every Solana Account requires a minimum balance of the native gas token to exist rent free. To send tokens to Solana, you will need a minimum amount of lamports to execute and initialize the account within the transaction.

Setting Extra Options

Any _options passed in the send call itself should be considered _extraOptions.

_extraOptions can specify additional handling within the same message type. These _options will then be combined with enforcedOption if set.

If not needed in your application, you should pass an empty bytes array 0x.

info

As outlined above, decide on whether you need an application wide option via enforcedOptions or a call specific option using extraOptions. Be specific in what _options you use for both parameters, as your transactions will reflect the exact settings you implement.

Estimating Fees and Calling Send

Now let's get an estimate of how much gas a transfer will cost to be sent and received.

To do this we can call the quoteWithUln function to return an estimate from the Endpoint contract to use as a recommended amount of gas needed to call send.

You will use this generated quote as the cost of the send transaction.

Besides the params listed above, there are also 3 optional params for quoteWithUln that may be relevant to your use case:

ParameterTypeDescription
tokenEscrowPublicKeyThe token escrow account for the OFT Adapter implementation. Required for quoting an OFT Adapter transfer.
payInZROboolWhether to pay the Endpoint in SOL or the LayerZero gas token. Defaults to false (i.e., pay in SOL).
composeMsgbytesThe compose message encoded.

See the OFT Adapter section for generating an OFT Adapter instance's quote and calling send.

import assert from 'assert';

// Import necessary functions and utilities from various libraries
import {fetchDigitalAsset} from '@metaplex-foundation/mpl-token-metadata';
import {
fetchAddressLookupTable,
findAssociatedTokenPda,
mplToolbox,
setComputeUnitLimit,
setComputeUnitPrice,
} from '@metaplex-foundation/mpl-toolbox';
import {
AddressLookupTableInput,
TransactionBuilder,
createSignerFromKeypair,
signerIdentity,
} from '@metaplex-foundation/umi';
import {createUmi} from '@metaplex-foundation/umi-bundle-defaults';
import {
fromWeb3JsInstruction,
fromWeb3JsKeypair,
fromWeb3JsPublicKey,
toWeb3JsPublicKey,
} from '@metaplex-foundation/umi-web3js-adapters';
import {TOKEN_PROGRAM_ID} from '@solana/spl-token';
import {Keypair, PublicKey} from '@solana/web3.js';
import {getExplorerLink, getSimulationComputeUnits} from '@solana-developers/helpers';
import bs58 from 'bs58';
import {hexlify} from 'ethers/lib/utils';
import {task} from 'hardhat/config';

// Import custom utilities from LayerZero
import {formatEid} from '@layerzerolabs/devtools';
import {types} from '@layerzerolabs/devtools-evm-hardhat';
import {EndpointId} from '@layerzerolabs/lz-definitions';
import {
OFT_SEED,
OftPDADeriver,
OftProgram,
OftTools,
SendHelper,
} from '@layerzerolabs/lz-solana-sdk-v2';
import {Options, addressToBytes32} from '@layerzerolabs/lz-v2-utilities';

// Import custom utility to create Solana connection factory
import {createSolanaConnectionFactory} from '../common/utils';
import getFee from '../utils/getFee';

// Define the interface for task arguments
interface Args {
amount: number;
to: string;
fromEid: EndpointId;
toEid: EndpointId;
programId: string;
mint: string;
}

// Define a lookup table for address mappings based on the endpoint ID
const LOOKUP_TABLE_ADDRESS: Partial<Record<EndpointId, PublicKey>> = {
[EndpointId.SOLANA_V2_MAINNET]: new PublicKey('AokBxha6VMLLgf97B5VYHEtqztamWmYERBmmFvjuTzJB'),
[EndpointId.SOLANA_V2_TESTNET]: new PublicKey('9thqPdbR27A1yLWw2spwJLySemiGMXxPnEvfmXVk4KuK'),
};

// Define a Hardhat task for sending OFT from Solana to an EVM chain
task('lz:oft:solana:send', 'Send tokens from Solana to a target EVM chain')
.addParam('amount', 'The amount of tokens to send', undefined, types.int)
.addParam('fromEid', 'The source endpoint ID', undefined, types.eid)
.addParam('to', 'The recipient address on the destination chain')
.addParam('toEid', 'The destination endpoint ID', undefined, types.eid)
.addParam('mint', 'The OFT token mint public key', undefined, types.string)
.addParam('programId', 'The OFT program ID', undefined, types.string)
.setAction(async (taskArgs: Args) => {
// Ensure the Solana private key is defined in the environment variables
const privateKey = process.env.SOLANA_PRIVATE_KEY;
assert(!!privateKey, 'SOLANA_PRIVATE_KEY is not defined in the environment variables.');

// Decode the private key and create a Keypair object for Solana
const keypair = Keypair.fromSecretKey(bs58.decode(privateKey));
const umiKeypair = fromWeb3JsKeypair(keypair);

// Retrieve the lookup table address for the specified source endpoint ID
const lookupTableAddress = LOOKUP_TABLE_ADDRESS[taskArgs.fromEid];
assert(lookupTableAddress != null, `No lookup table found for ${formatEid(taskArgs.fromEid)}`);

// Initialize the Solana connection and UMI framework
const connectionFactory = createSolanaConnectionFactory();
const connection = await connectionFactory(taskArgs.fromEid);
const umi = createUmi(connection.rpcEndpoint).use(mplToolbox());
const umiWalletSigner = createSignerFromKeypair(umi, umiKeypair);
umi.use(signerIdentity(umiWalletSigner));

// Define the OFT program and token mint public keys
const oftProgramId = new PublicKey(taskArgs.programId);
const mintPublicKey = new PublicKey(taskArgs.mint);
const umiMintPublicKey = fromWeb3JsPublicKey(mintPublicKey);

// Find the associated token account for the wallet
const tokenAccount = findAssociatedTokenPda(umi, {
mint: umiMintPublicKey,
owner: umiWalletSigner.publicKey,
});

// Derive the OFT configuration PDA (Program Derived Address)
const [oftConfigPda] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), mintPublicKey.toBuffer()],
oftProgramId,
);

// Fetch metadata for the token mint
const mintInfo = (await fetchDigitalAsset(umi, umiMintPublicKey)).mint;
const destinationEid: EndpointId = taskArgs.toEid;
const amount = taskArgs.amount * 10 ** mintInfo.decimals;

// Derive peer address and fetch peer account information
const deriver = new OftPDADeriver(oftProgramId);
const [peerAddress] = deriver.peer(oftConfigPda, destinationEid);
const peerInfo = await OftProgram.accounts.Peer.fromAccountAddress(connection, peerAddress);

// Initialize send helper and convert recipient address to bytes32 format
const sendHelper = new SendHelper();
const recipientAddressBytes32 = addressToBytes32(taskArgs.to);

// Quote the fee for the cross-chain transfer using ULN (Ultra Light Node)
const feeQuote = await OftTools.quoteWithUln(
connection,
keypair.publicKey, // Payer
mintPublicKey, // Token mint public key
destinationEid, // Destination endpoint ID
BigInt(amount), // Amount to send
(BigInt(amount) * BigInt(9)) / BigInt(10), // Adjusted amount
Options.newOptions().addExecutorLzReceiveOption(0, 0).toBytes(), // Options for ULN
Array.from(recipientAddressBytes32), // Recipient address in bytes32 format
false, // payInZRO (Use ZRO token for fees)
undefined, // tokenEscrow (Optional)
undefined, // composeMsg (Optional)
peerInfo.address, // Peer address
await sendHelper.getQuoteAccounts(
connection,
keypair.publicKey,
oftConfigPda,
destinationEid,
hexlify(peerInfo.address),
),
TOKEN_PROGRAM_ID, // SPL Token Program ID
oftProgramId, // OFT Program ID
);

console.log(feeQuote);

// Create the instruction for sending tokens with ULN
const sendInstruction = await OftTools.sendWithUln(
connection,
keypair.publicKey, // Payer
mintPublicKey, // Token mint public key
toWeb3JsPublicKey(tokenAccount[0]), // Source token account
destinationEid, // Destination endpoint ID
BigInt(amount), // Amount to send
(BigInt(amount) * BigInt(9)) / BigInt(10), // Adjusted amount
Options.newOptions().addExecutorLzReceiveOption(0, 0).toBytes(), // Options for ULN
Array.from(recipientAddressBytes32), // Recipient address in bytes32 format
feeQuote.nativeFee, // Fee amount
undefined, // payInZRO (Optional)
undefined, // composeMsg (Optional)
peerInfo.address, // Peer address
undefined, // Optional fields
TOKEN_PROGRAM_ID, // SPL Token Program ID
oftProgramId, // OFT Program ID
);

// Convert the instruction and create a transaction builder
const convertedInstruction = fromWeb3JsInstruction(sendInstruction);
const transactionBuilder = new TransactionBuilder([
{
instruction: convertedInstruction,
signers: [umiWalletSigner],
bytesCreatedOnChain: 0,
},
]);

// Fetch compute unit details and set the compute unit price
const {averageFeeExcludingZeros} = await getFee();
const priorityFee = Math.round(averageFeeExcludingZeros);
const computeUnitPrice = BigInt(priorityFee);
console.log(`Compute unit price: ${computeUnitPrice}`);

// Fetch the address lookup table and simulation compute units
const addressLookupTableInput: AddressLookupTableInput = await fetchAddressLookupTable(
umi,
fromWeb3JsPublicKey(lookupTableAddress),
);
const {value: lookupTableAccount} = await connection.getAddressLookupTable(
new PublicKey(lookupTableAddress),
);
const computeUnits = await getSimulationComputeUnits(
connection,
[sendInstruction],
keypair.publicKey,
[lookupTableAccount!],
);

// Build and send the transaction
const transactionSignature = await transactionBuilder
.add(setComputeUnitPrice(umi, {microLamports: computeUnitPrice * BigInt(4)})) // Set compute unit price
.add(setComputeUnitLimit(umi, {units: computeUnits! * 1.1})) // Set compute unit limit
.setAddressLookupTables([addressLookupTableInput]) // Add address lookup table
.sendAndConfirm(umi); // Send and confirm the transaction

// Encode the transaction signature and generate explorer links
const transactionSignatureBase58 = bs58.encode(transactionSignature.signature);
const solanaTxLink = getExplorerLink(
'tx',
transactionSignatureBase58.toString(),
'mainnet-beta',
);
const layerZeroTxLink = `https://layerzeroscan.com/tx/${transactionSignatureBase58}`;

// Log success messages with links to view the transactions
console.log(`✅ Sent ${taskArgs.amount} token(s) to destination EID: ${destinationEid}!`);
console.log(`View Solana transaction here: ${solanaTxLink}`);
console.log(`Track cross-chain transfer here: ${layerZeroTxLink}`);
});

Similar to the quote params, the send instruction also has multiple optional params for the createSendWithUlnIx:

ParameterTypeDescription
tokenEscrowPublicKeyThe token escrow account for the OFT Adapter implementation. Required for quoting an OFT Adapter transfer.
payInZROboolWhether to pay the Endpoint in SOL or the LayerZero gas token. Defaults to false (i.e., pay in SOL).
composeMsgbytesThe compose message encoded.

See the OFT Adapter section for generating an OFT Adapter instance's quote and calling send.

_lzReceive tokens

A successful send call will be delivered to the destination chain, invoking the provided _lzReceive method during execution on the destination chain, for example an EVM equivalent:

function _lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata _message,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal virtual override {
// @dev sendTo is always a bytes32 as the remote chain initiating the call doesnt know remote chain address size
address toAddress = _message.sendTo().bytes32ToAddress();

uint256 amountToCreditLD = _toLD(_message.amountSD());
uint256 amountReceivedLD = _credit(toAddress, amountToCreditLD, _origin.srcEid);

if (_message.isComposed()) {
bytes memory composeMsg = OFTComposeMsgCodec.encode(
_origin.nonce,
_origin.srcEid,
amountReceivedLD,
_message.composeMsg()
);
// @dev Stores the lzCompose payload that will be executed in a separate tx.
// standardizes functionality for executing arbitrary contract invocation on some non-evm chains.
// @dev Composed toAddress is the same as the receiver of the oft/tokens
// TODO need to document the index / understand how to use it properly
endpoint.sendCompose(toAddress, _guid, 0, composeMsg);
}

emit OFTReceived(_guid, toAddress, amountToCreditLD, amountReceivedLD);
}

_credit:

When receiving the message on your destination contract, _credit is invoked, triggering the final steps to mint an ERC20 token on the destination to the specified address.

function _credit(
address _to,
uint256 _amountToCreditLD,
uint32 /*_srcEid*/
) internal virtual override returns (uint256 amountReceivedLD) {
_mint(_to, _amountToCreditLD);
return _amountToCreditLD;
}

Additional Information

Token Supply Cap

When transferring tokens across different blockchain VMs, each chain may have a different level of decimal precision for the smallest unit of a token.

While EVM chains support uint256 for token balances, Solana uses uint64. Because of this, the default OFT Standard has a max token supply (2^64 - 1)/(10^6), or 18,446,744,073,709.551615.

info

If your token's supply needs to exceed this limit, you'll need to override the shared decimals value.

Optional: Overriding sharedDecimals

This shared decimal precision is essentially the maximum number of decimal places that can be reliably represented and handled across different blockchain VMs when transferring tokens.

By default, an OFT has 6 sharedDecimals, which is optimal for most ERC20 use cases that use 18 decimals.

// @dev Sets an implicit cap on the amount of tokens, over uint64.max() will need some sort of outbound cap / totalSupply cap
// Lowest common decimal denominator between chains.
// Defaults to 6 decimal places to provide up to 18,446,744,073,709.551615 units (max uint64).
// For tokens exceeding this totalSupply(), they will need to override the sharedDecimals function with something smaller.
// ie. 4 sharedDecimals would be 1,844,674,407,370,955.1615
const OFT_DECIMALS = 6;

To modify this default, simply change the OFT_DECIMALS to another value during deployment.

caution

Shared decimals also control how token transfer precision is calculated.

Token Transfer Precision

The OFT Standard also handles differences in decimal precision before every cross-chain transfer by "cleaning" the amount from any decimal precision that cannot be represented in the shared system.

The OFT Standard defines these small token transfer amounts as "dust".

Example

ERC20 OFTs use a local decimal value of 18 (the norm for ERC20 tokens), and a shared decimal value of 6 (the norm for Solana tokens).

decimalConversionRate = 10^(localDecimals − sharedDecimals) = 10^(18−6) = 10^12

This means the conversion rate is 10^12, which indicates the smallest unit that can be transferred is 10^-12 in terms of the token's local decimals.

For example, if you send a value of 1234567890123456789 (a token amount with 18 decimals), the OFT Standard will:

  1. Divides by decimalConversionRate:
1234567890123456789 / 10^12 = 1234567.890123456789 = 1234567
tip

Remember that solidity performs integer arithmetic. This means when you divide two integers, the result is also an integer with the fractional part discarded.


  1. Multiplies by decimalConversionRate:
1234567 * 10^12 = 1234567000000000000

This process removes the last 12 digits from the original amount, effectively "cleaning" the amount from any "dust" that cannot be represented in a system with 6 decimal places.

Adding Send and Receive Logic

In Solana, the concept of function overrides as commonly understood in object-oriented languages like Solidity does not directly apply. Because of this, to change or add any custom business logic to the token, you will need to deploy your own variant of the OFT Program.

For more information, visit the OFT Program Library.