LayerZero V2 Solana OFT Native Account
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.
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.
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
- yarn
- pnpm
npm install @layerzerolabs/lz-solana-sdk-v2
yarn add @layerzerolabs/lz-solana-sdk-v2
pnpm add @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.
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 Name | Executable | Description |
---|---|---|
OFT Program | true | The OFT Program itself, the executable, stateless code which controls how OFTs interact with the LayerZero Endpoint and the SPL Token. |
SPL Token Program | true | The SPL Token Program itself, the executable, stateless code which controls the token interface for Solana. |
SPL Mint Account | false | Effectively a global counter for a specific token, stores data such as total supply, decimal precision, mint authority, and freeze authority. |
OFT Config Account | false | Stores 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 Account | false | A 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):
- OFT
- OFT Adapter
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.
Once the SPL Token Mint Authority has been transferred, it cannot be removed from the OFT Config without upgrading the OFT Program.
An OFT Config initialized using the createInitAdapterOftIx
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 escrow account lockbox keypair
const [oftConfig] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), lockboxKeypair.publicKey.toBuffer()],
OFT_PROGRAM_ID,
);
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.
In this setup, the OFT Config does not directly mint or burn tokens but instead controls their distribution through locking and unlocking mechanisms:
_debit
(Locking Tokens): The OFT Config can lock tokens in the escrow account. This means that while the tokens remain on the blockchain, they are not available for use by the original owner or anyone else, effectively removing them from circulation without actually burning them._credit
(Unlocking Tokens): Correspondingly, the OFT Config can unlock tokens from escrow, returning them to circulation under specific conditions, typically when tokens are transferred back to Solana from another chain.
If you have already deployed an SPL Token and do not want to or cannot transfer the mint authority, you should use the Solana OFT Adapter.
There can only be one OFT Adapter used in an OFT deployment. Multiple OFT Adapters break omnichain unified liquidity by effectively creating token pools. If you create OFT Adapters on multiple chains, you have no way to guarantee finality for token transfers due to the fact that the source chain has no knowledge of the destination lockbox's supply (or lack of supply). This can create race conditions where if a sent amount exceeds the available supply on the destination chain, those sent tokens will be permanently lost.
The createInitAdapterOftIx
instruction creates an OFT Config that acts as a "lockbox", enabling existing Solana Token Mints that cannot transfer the mint authority to use the OFT Standard.
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.
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.
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.
- @metaplex-foundation/umi
- @solana/web3.js
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}`);
});
Using @solana/web3.js
and the @layerzerolabs/lz-solana-sdk-v2
:
// Import necessary functions and classes from Solana SDKs
import {
Connection,
Keypair,
PublicKey,
Transaction,
sendAndConfirmTransaction,
SystemProgram,
} from '@solana/web3.js';
import {
PROGRAM_ID,
createCreateMetadataAccountV3Instruction,
} from '@metaplex-foundation/mpl-token-metadata';
import {getKeypairFromEnvironment, getExplorerLink} from '@solana-developers/helpers';
import {OftTools, OFT_SEED} from '@layerzerolabs/lz-solana-sdk-v2';
import {env} from 'process';
// Import necessary components and types
import {task} from 'hardhat/config';
import {TaskArguments} from 'hardhat/types';
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) => {
const {
AuthorityType,
TOKEN_PROGRAM_ID,
createInitializeMintInstruction,
createSetAuthorityInstruction,
getOrCreateAssociatedTokenAccount,
getMintLen,
} = await import('@solana/spl-token');
let RPC_URL_SOLANA: string;
// Connect to the Solana cluster (devnet in this case)
if (taskArgs.staging == 'mainnet') {
RPC_URL_SOLANA = env.RPC_URL_SOLANA?.toString() ?? 'default_url';
} else if (taskArgs.staging == 'testnet') {
RPC_URL_SOLANA = env.RPC_URL_SOLANA_TESTNET?.toString() ?? 'default_url';
} else {
throw new Error("Invalid network specified. Use 'mainnet' or 'testnet'.");
}
const connection = new Connection(RPC_URL_SOLANA, 'confirmed');
// Your OFT_PROGRAM_ID
const OFT_PROGRAM_ID = new PublicKey(taskArgs.program);
// The TOKEN METADATA PROGRAM ID
const TOKEN_METADATA_PROGRAM_ID = PROGRAM_ID;
const walletKeyPair = getKeypairFromEnvironment('SOLANA_PRIVATE_KEY');
// Generate SPL TOKEN Mint Keypair
const mintKeyPair = Keypair.generate();
// 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;
//
// 1. MINT NEW SPL TOKEN
//
const minimumBalanceForMint = await connection.getMinimumBalanceForRentExemption(
getMintLen([]),
);
let transaction = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: walletKeyPair.publicKey,
newAccountPubkey: mintKeyPair.publicKey,
space: getMintLen([]),
lamports: minimumBalanceForMint,
programId: TOKEN_PROGRAM_ID,
}),
await createInitializeMintInstruction(
mintKeyPair.publicKey, // mint public key
LOCAL_DECIMALS, // decimals
walletKeyPair.publicKey, // mint authority
null, // freeze authority (not used here)
TOKEN_PROGRAM_ID, // token program id
),
);
const tokenMint = await sendAndConfirmTransaction(
connection,
transaction,
[walletKeyPair, mintKeyPair],
{commitment: `finalized`},
);
const link = getExplorerLink('tx', tokenMint, taskArgs.staging);
console.log(`✅ Token Mint Complete! View the transaction here: ${link}`);
// Need to create an associated token account.
const tokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
walletKeyPair,
mintKeyPair.publicKey,
walletKeyPair.publicKey,
undefined,
`finalized`,
{commitment: `finalized`},
TOKEN_PROGRAM_ID,
);
// Derive the Metadata Account using the SPL Token.
const oftMetadataPDA = PublicKey.findProgramAddressSync(
[
Buffer.from('metadata'),
TOKEN_METADATA_PROGRAM_ID.toBuffer(),
mintKeyPair.publicKey.toBuffer(),
],
TOKEN_METADATA_PROGRAM_ID,
)[0];
// Supply the Metadata using the token mint.
// You can find the Token metadata uploaded to Arweave in ./utils/token.json
let createMetadataInstructionTransaction = new Transaction().add(
await createCreateMetadataAccountV3Instruction(
{
metadata: oftMetadataPDA,
mint: mintKeyPair.publicKey,
mintAuthority: walletKeyPair.publicKey,
payer: walletKeyPair.publicKey,
updateAuthority: walletKeyPair.publicKey,
},
{
createMetadataAccountArgsV3: {
collectionDetails: null,
data: {
collection: null,
creators: null,
name: 'OFT',
sellerFeeBasisPoints: 0,
symbol: 'OFT',
uri: '',
uses: null,
},
isMutable: true,
},
},
),
);
const tokenMetaDataTx = await sendAndConfirmTransaction(
connection,
createMetadataInstructionTransaction,
[walletKeyPair],
{commitment: `finalized`},
);
const metadataSetLink = getExplorerLink('tx', tokenMetaDataTx, taskArgs.staging);
console.log(`✅ Token Metadata Added! View the transaction here: ${metadataSetLink}`);
// Derive the OFT Config's PDA
const [oftConfig] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), mintKeyPair.publicKey.toBuffer()],
OFT_PROGRAM_ID,
);
console.log(`OFT Config:`, oftConfig);
//
// 2. Create a new tx to transfer mint authority to OFT Config Account and initialize a new native OFT
//
transaction = new Transaction().add(
createSetAuthorityInstruction(
mintKeyPair.publicKey, // mint public key
walletKeyPair.publicKey, // current authority
AuthorityType.MintTokens, // authority type
oftConfig, // new authority
[], // multisig owners (none in this case)
TOKEN_PROGRAM_ID, // token program id
),
await OftTools.createInitNativeOftIx(
walletKeyPair.publicKey, // payer
walletKeyPair.publicKey, // adminx
mintKeyPair.publicKey, // mint account
walletKeyPair.publicKey, // OFT Mint Authority
SHARED_DECIMALS,
TOKEN_PROGRAM_ID,
OFT_PROGRAM_ID, // OFT Program ID that I deployed
),
);
// Send the transaction to initialize the OFT
const oftSignature = await sendAndConfirmTransaction(connection, transaction, [walletKeyPair], {
commitment: `finalized`,
});
const oftLink = getExplorerLink('tx', oftSignature, taskArgs.staging);
console.log('You created a Native OFT, view the transaction here:', oftLink);
// 2a. OPTIONAL MINT WITH OFT
if (taskArgs.amount) {
const oftMintTransaction = new Transaction().add(
await OftTools.createMintToIx(
walletKeyPair.publicKey,
mintKeyPair.publicKey,
tokenAccount.address, // which account to mint to?
BigInt(taskArgs.amount),
TOKEN_PROGRAM_ID,
OFT_PROGRAM_ID,
),
);
// Send the transaction to mint the OFT tokens
const oftMintSignature = await sendAndConfirmTransaction(
connection,
oftMintTransaction,
[walletKeyPair],
{commitment: `finalized`},
);
const oftMintLink = getExplorerLink('tx', oftMintSignature, taskArgs.staging);
console.log(
`✅ You minted ${taskArgs.amount} tokens! View the transaction here: ${oftMintLink}`,
);
}
});
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.
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.
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.
- @metaplex-foundation/umi
- @solana/web3.js
// 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,
)}`,
);
});
// Import necessary functions and classes from Solana SDKs
import fs from 'fs';
import {
Connection,
Keypair,
PublicKey,
Transaction,
sendAndConfirmTransaction,
SystemProgram,
} from '@solana/web3.js';
import {
getKeypairFromEnvironment,
getExplorerLink,
getSimulationComputeUnits,
} from '@solana-developers/helpers';
import {OftTools, OFT_SEED} from '@layerzerolabs/lz-solana-sdk-v2';
import {env} from 'process';
import {task} from 'hardhat/config';
import {TaskArguments} from 'hardhat/types';
import {web3} from '@coral-xyz/anchor';
import getFee from './utils/getFee';
import {ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID} from '@solana/spl-token';
task('lz:solana:oft:create:adapter', 'Mints new SPL Token and creates new OFT Config account')
.addParam('program', 'The OFT Program id')
.addParam('staging', 'Solana mainnet or testnet')
.setAction(async (taskArgs: TaskArguments) => {
const {
createInitializeMintInstruction,
createMintToInstruction,
createAssociatedTokenAccountInstruction,
getMintLen,
} = await import('@solana/spl-token');
const RPC_URL_SOLANA = getRpcUrl(taskArgs.staging);
const connection = new Connection(RPC_URL_SOLANA, 'confirmed');
const walletKeyPair = getKeypairFromEnvironment('SOLANA_PRIVATE_KEY');
const mintKeyPair = Keypair.generate();
const lockboxKeyPair = Keypair.generate();
const OFT_PROGRAM_ID = new PublicKey(taskArgs.program);
const LOCAL_DECIMALS = 9;
const SHARED_DECIMALS = 6;
// Get RPC URL based on network type
function getRpcUrl(staging: string): string {
if (staging === 'mainnet') {
return env.RPC_URL_SOLANA?.toString() ?? 'default_url';
} else if (staging === 'testnet') {
return env.RPC_URL_SOLANA_TESTNET?.toString() ?? 'default_url';
} else {
throw new Error("Invalid network specified. Use 'mainnet' or 'testnet'.");
}
}
// Calculate and add compute units and price for a transaction
async function addComputeUnits(
tx: Transaction,
instructions: web3.TransactionInstruction[],
connection: Connection,
walletKeyPair: Keypair,
) {
const {averageFeeExcludingZeros} = await getFee();
const priorityFee = MaverageFeeExcludingZeros;
const units = await getSimulationComputeUnits(
connection,
instructions,
walletKeyPair.publicKey,
[],
);
const computeUnitPrice = BigInt(Math.round(priorityFee));
tx.add(web3.ComputeBudgetProgram.setComputeUnitPrice({microLamports: computeUnitPrice}));
}
// Mint new SPL token
async function mintSPLToken() {
const minimumBalanceForMint = await connection.getMinimumBalanceForRentExemption(
getMintLen([]),
);
const transaction = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: walletKeyPair.publicKey,
newAccountPubkey: mintKeyPair.publicKey,
space: getMintLen([]),
lamports: minimumBalanceForMint,
programId: TOKEN_PROGRAM_ID,
}),
);
await logBalance(walletKeyPair.publicKey, 'User');
await logBalance(mintKeyPair.publicKey, 'Mint Account');
await sendAndConfirmTransaction(connection, transaction, [walletKeyPair, mintKeyPair], {
commitment: 'finalized',
});
const ix = createInitializeMintInstruction(
mintKeyPair.publicKey, // mint public key
LOCAL_DECIMALS, // decimals
walletKeyPair.publicKey, // mint authority
null, // freeze authority (burned)
TOKEN_PROGRAM_ID, // token program id
);
let tokenTransaction = new Transaction().add(ix);
await addComputeUnits(tokenTransaction, [ix], connection, walletKeyPair);
const tokenMint = await sendAndConfirmTransaction(
connection,
tokenTransaction,
[walletKeyPair, mintKeyPair],
{commitment: 'finalized'},
);
console.log(
`✅ Token Mint Complete! View the transaction here: ${getExplorerLink(
'tx',
tokenMint,
taskArgs.staging,
)}`,
);
return tokenMint;
}
// Initialize OFT adapter
async function initializeOFTAdapter() {
const [oftConfig] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), lockboxKeyPair.publicKey.toBuffer()],
OFT_PROGRAM_ID,
);
console.log(`OFT Config:`, oftConfig);
const transaction = new Transaction().add(
await OftTools.createInitAdapterOftIx(
walletKeyPair.publicKey, // payer
walletKeyPair.publicKey, // admin
mintKeyPair.publicKey, // mint account
lockboxKeyPair.publicKey,
SHARED_DECIMALS, // shared token decimals
TOKEN_PROGRAM_ID, // token program ID
OFT_PROGRAM_ID, // OFT Program ID that I deployed
),
);
await addComputeUnits(transaction, transaction.instructions, connection, walletKeyPair);
const oftSignature = await sendAndConfirmTransaction(
connection,
transaction,
[walletKeyPair, lockboxKeyPair],
{commitment: 'finalized'},
);
console.log(
'You created a Native OFT, view the transaction here:',
getExplorerLink('tx', oftSignature, taskArgs.staging),
);
return oftConfig;
}
// Main execution flow
await mintSPLToken();
const oftConfig = await initializeOFTAdapter();
});
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.
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.
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
.
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.
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.
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);
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
.
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:
Parameter | Type | Description |
---|---|---|
tokenEscrow | PublicKey | The token escrow account for the OFT Adapter implementation. Required for quoting an OFT Adapter transfer. |
payInZRO | bool | Whether to pay the Endpoint in SOL or the LayerZero gas token. Defaults to false (i.e., pay in SOL). |
composeMsg | bytes | The compose message encoded. |
See the OFT Adapter section for generating an OFT Adapter instance's quote
and calling send
.
- @metaplex-foundation/umi
- @solana/web3.js
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}`);
});
// Import necessary functions and classes from Solana SDKs and LayerZero utilities
import assert from 'assert';
import {env} from 'process';
import {web3} from '@coral-xyz/anchor';
import {TOKEN_PROGRAM_ID, getMint, getOrCreateAssociatedTokenAccount} from '@solana/spl-token';
import {Connection, PublicKey} from '@solana/web3.js';
import {
getExplorerLink,
getKeypairFromEnvironment,
getSimulationComputeUnits,
} from '@solana-developers/helpers';
import {hexlify} from 'ethers/lib/utils';
import {task} from 'hardhat/config';
import {EndpointId} from '@layerzerolabs/lz-definitions';
import {formatEid} from '@layerzerolabs/devtools';
import {types} from '@layerzerolabs/devtools-evm-hardhat';
import {
OFT_SEED,
OftPDADeriver,
OftProgram,
OftTools,
SendHelper,
buildVersionedTransaction,
} from '@layerzerolabs/lz-solana-sdk-v2';
import {Options, addressToBytes32} from '@layerzerolabs/lz-v2-utilities';
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:solana:oft:send', 'Sends 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.');
// 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)}`);
// Create a connection to the Solana network
const connection = new Connection(env.RPC_URL_SOLANA!, 'confirmed');
// Retrieve the wallet keypair from the environment
const walletKeyPair = getKeypairFromEnvironment('SOLANA_PRIVATE_KEY');
const mintPublicKey = new PublicKey(taskArgs.mint);
const OFT_PROGRAM_ID = new PublicKey(taskArgs.programId);
// Create an associated token account if it doesn't exist
const tokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
walletKeyPair,
mintPublicKey,
walletKeyPair.publicKey,
undefined,
`finalized`,
{commitment: `finalized`},
TOKEN_PROGRAM_ID,
);
// Derive the OFT Config's PDA (Program Derived Address)
const [oftConfigPda] = PublicKey.findProgramAddressSync(
[Buffer.from(OFT_SEED), mintPublicKey.toBuffer()],
OFT_PROGRAM_ID,
);
// Fetch mint information to get token decimals
const mintInfo = await getMint(connection, mintPublicKey);
const dstEid: EndpointId = taskArgs.toEid;
const amount = taskArgs.amount * 10 ** mintInfo.decimals;
// Derive the peer address and fetch peer account information
const deriver = new OftPDADeriver(OFT_PROGRAM_ID);
const [peerAddress] = deriver.peer(oftConfigPda, dstEid);
const peerInfo = await OftProgram.accounts.Peer.fromAccountAddress(connection, peerAddress);
const sendHelper = new SendHelper();
// Convert the recipient address to bytes32 format
const receiverAddressBytes32 = addressToBytes32(taskArgs.to);
// Quote the fee required for the cross-chain transfer
const feeQuote = await OftTools.quoteWithUln(
connection,
walletKeyPair.publicKey,
mintPublicKey,
dstEid,
BigInt(amount),
(BigInt(amount) * BigInt(9)) / BigInt(10), // Adjusted amount for fees
Options.newOptions().addExecutorLzReceiveOption(0, 0).toBytes(), // Additional execution options
Array.from(receiverAddressBytes32),
false, // payInZRO (whether to pay fees in ZRO token)
undefined, // tokenEscrow (optional)
undefined, // composeMsg (optional)
peerInfo.address, // Peer address
await sendHelper.getQuoteAccounts(
connection,
walletKeyPair.publicKey,
oftConfigPda,
dstEid,
hexlify(peerInfo.address),
),
TOKEN_PROGRAM_ID, // SPL Token Program ID
OFT_PROGRAM_ID, // OFT Program ID
);
// Create the instruction to send tokens using OFT
const sendInstruction = await OftTools.sendWithUln(
connection,
walletKeyPair.publicKey, // Payer
mintPublicKey, // Token mint public key
tokenAccount.address, // Source token account
dstEid, // Destination endpoint ID
BigInt(amount), // Amount to send
(BigInt(amount) * BigInt(9)) / BigInt(10), // Adjusted amount for fees
Options.newOptions().addExecutorLzReceiveOption(0, 0).toBytes(), // Additional execution options
Array.from(receiverAddressBytes32),
feeQuote.nativeFee, // Fee amount
undefined, // payInZRO (optional)
undefined, // composeMsg (optional)
peerInfo.address, // Peer address
undefined, // Optional fields
TOKEN_PROGRAM_ID, // SPL Token Program ID
OFT_PROGRAM_ID, // OFT Program ID
);
// Fetch the latest block hash for transaction finalization
const latestBlockHash = await connection.getLatestBlockhash();
// Get prioritization fees for compute units
const {averageFeeExcludingZeros} = await getFee();
const priorityFee = Math.round(averageFeeExcludingZeros);
// Adjust compute units based on simulation results
const {value: lookupTableAccount} = await connection.getAddressLookupTable(
new PublicKey(lookupTableAddress),
);
const computeUnits = await getSimulationComputeUnits(
connection,
[sendInstruction],
walletKeyPair.publicKey,
[lookupTableAccount!],
);
const adjustedComputeUnits =
computeUnits === null ? 1000 : computeUnits < 1000 ? 1000 : Math.ceil(computeUnits * 1.1);
// Set the compute unit limit and price (priority fee)
const setComputeUnitLimit = web3.ComputeBudgetProgram.setComputeUnitLimit({
units: adjustedComputeUnits,
});
const computeUnitPrice = BigInt(Math.round((priorityFee / computeUnits!) * 1.5));
const setComputeUnitPrice = web3.ComputeBudgetProgram.setComputeUnitPrice({
microLamports: computeUnitPrice,
});
// Build the transaction with compute unit settings and the transfer instruction
const transaction = await buildVersionedTransaction(
connection,
walletKeyPair.publicKey,
[setComputeUnitLimit, setComputeUnitPrice, sendInstruction],
`confirmed`,
undefined,
new PublicKey(lookupTableAddress),
);
// Sign the transaction with the wallet keypair
transaction.sign([walletKeyPair]);
// Send the transaction to the Solana network
const transactionHash = await connection.sendTransaction(transaction, {
skipPreflight: true,
maxRetries: 1,
});
console.log(transactionHash);
// Confirm the transaction on the Solana network
await connection.confirmTransaction({
blockhash: latestBlockHash.blockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
signature: transactionHash,
});
// Display transaction links for Solana Explorer and LayerZero Scan
const solanaExplorerLink = getExplorerLink('tx', transactionHash, 'mainnet-beta');
console.log(
`✅ You sent ${taskArgs.amount} token(s) to dstEid: ${dstEid}! View the Solana transaction here: ${solanaExplorerLink}. See: https://layerzeroscan.com/tx/${transactionHash} to follow your cross-chain transfer.`,
);
});
Similar to the quote
params, the send
instruction also has multiple optional params for the createSendWithUlnIx
:
Parameter | Type | Description |
---|---|---|
tokenEscrow | PublicKey | The token escrow account for the OFT Adapter implementation. Required for quoting an OFT Adapter transfer. |
payInZRO | bool | Whether to pay the Endpoint in SOL or the LayerZero gas token. Defaults to false (i.e., pay in SOL). |
composeMsg | bytes | The 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
.
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.
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:
- Divides by
decimalConversionRate
:
1234567890123456789 / 10^12 = 1234567.890123456789 = 1234567
Remember that solidity performs integer arithmetic. This means when you divide two integers, the result is also an integer with the fractional part discarded.
- 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.