Skip to main content
Version: Endpoint V2

Solana OFT

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

While the typical path for Solana program development involves interacting with or deploying executable code that defines your specific implementation, and then minting accounts that want to use that interface (e.g., the SPL Token Program), the OFT Program is different in this respect.

Because every Solana Program has an Upgrade Authority, and this authority can change or modify the implementation of all child accounts, developers wishing to create cross-chain tokens on Solana should deploy their own instance of the OFT Program to create new OFT Store accounts, so that they own their OFT's Upgrade Authority.

note

End-to-end instruction on how to deploy a Solana OFT can be found in the README at https://github.com/LayerZero-Labs/devtools/tree/main/examples/oft-solana, which will be the README of your project when you setup using the LayerZero CLI.

Quickstart

Example

For the step-by-step instructions on how to build, deploy and wire a Solana OFT, view the Solana OFT example.

Scaffold

Spin up a new OFT workspace (based on the example) in seconds:

LZ_ENABLE_SOLANA_OFT_EXAMPLE=1 npx create-lz-oapp@latest

Specify the directory, select OFT (Solana) and proceed with the installation.

Follow the provided README instructions to make your first cross-chain OFT transfer between Solana and an EVM chain.

The rest of this page contains additional information that you should read before deploying to mainnet.

Prerequisite Knowledge

Understanding the following will help you with the rest of this page:

The OFT Program

The OFT Program interacts with the Solana Token Program to allow new or existing Fungible Tokens on Solana to transfer balances between different chains.

info

Solana now has two token programs. The original Token Program (commonly referred to as 'SPL token') and the newer Token-2022 program.

LayerZero's OFT Standard introduces the OFT Store, a Program Derived Address (PDA) account responsible for storing your token's specific LayerZero configuration and enabling cross-chain transfers for Solana tokens.

Solana Token Program Solana Token Program

Each OFT Store Account is managed by an OFT Program, which you would have already deployed in the previous step. To read more on the various programs and accounts involved in creating a Solana OFT, refer to the below section on the OFT Account Model.

You can use the same OFT Program to create multiple Solana OFTs.

info

If using the same repo, you will need to rename the existing deployments/solana-<CLUSTER_NAME>/OFT.json as it will be overwritten otherwise. You will also need to either rename the existing layerzero.config.ts or use a different config file for the subsequent OFTs.

OFT Account Model

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

The Solana OFT Standard uses 6 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.
Mint AccountfalseThis is the Mint Account for the OFT's SPL Token. Stores the key metadata for a specific token, such as total supply, decimal precision, mint authority, freeze authority and update authority.
Mint Authority MultisigfalseA 1 of N Multisig that serves as the Mint Authority for the SPL Token. The OFT Store is always required as a signer. It's also possible to add additional signers.
EscrowfalseThe Token Account for the corresponding Mint Account, owned by the OFT Store. For OFT Adapter deployments and also for storing fees, if fees are enabled. For both OFT and OFT Adapter, the Escrow address is part of the derivation for the OFT Store PDA. Escrow is a regular Token Account and not an Associated Token Account.
OFT StorefalseA PDA account that stores data about each OFT such as the underlying SPL Token Mint, the SPL Token Program, Endpoint Program, the OFT's fee structure, and extensions. Is the owner for the Escrow account. The OFT Store is a signer for the Mint Authority multisig.
PeerConfigfalseA PDA account that stores configuration for each remote chain, including peer addresses, enforced options, rate limiters, and fee settings. This account is derived from the OFT Store and remote EID.
info

The SPL Token Program handles all creation and management of SPL tokens on the Solana blockchain. An OFT's deployment interacts with this program to create the Mint Account.

Message Execution Options

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

Note that you must have at least either enforcedOptions set for your OApp or extraOptions passed in for a particular transaction. If both are absent, the transaction will fail. For sends from EVM chains, quoteSend() will revert. For sends from Solana, you will see a ZeroLzReceiveGasProvided error.

If you had set enforcedOptions, then you can pass an empty bytes array (0x if sending from EVM, Buffer.from('') if sending from Solana).

If you did not set enforcedOptions, then continue reading.

Setting Extra Options

Any _options passed in the send call itself is considered as _extraOptions.

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

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.

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.

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.

Setting Options Inbound to EVM chains

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.

To pass in extraOptions for Solana to EVM (Sepolia, in our example) transactions, modify tasks/solana/sendOFT.ts

Refer to the sample code diff below:

import {addressToBytes32, Options} from '@layerzerolabs/lz-v2-utilities';
// ...
// add the following 3 lines anywhere before the `oft.quote()` call
const GAS_LIMIT = 60_000 // Gas limit for the executor
const MSG_VALUE = 0 // msg.value for the lzReceive() function on destination in wei
const _options = Options.newOptions().addExecutorLzReceiveOption(GAS_LIMIT, MSG_VALUE)
// ...
// replace the options value in oft.quote()
const { nativeFee } = await oft.quote(
umi.rpc,
{
payer: umiWalletSigner.publicKey,
tokenMint: mint,
tokenEscrow: umiEscrowPublicKey,
},
{
payInLzToken: false,
to: Buffer.from(recipientAddressBytes32),
dstEid: toEid,
amountLd: BigInt(amount),
minAmountLd: 1n,
options: _options.toBytes(), // <--- here
composeMsg: undefined,
},
// ...
// replace the options value in oft.send()
const ix = await oft.send(
umi.rpc,
{
payer: umiWalletSigner,
tokenMint: mint,
tokenEscrow: umiEscrowPublicKey,
tokenSource: tokenAccount[0],
},
{
to: Buffer.from(recipientAddressBytes32),
dstEid: toEid,
amountLd: BigInt(amount),
minAmountLd: (BigInt(amount) * BigInt(9)) / BigInt(10),
options: _options.toBytes(), // <--- here
composeMsg: undefined,
nativeFee,
},
// ...

We will call this script later in Message Execution Options.

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 Options Inbound to Solana

When sending to Solana, a msg.value is only required if the recipient address does not already have the Associated Token Account (ATA) for your mint. There are two ways to provide this value:

  • Enforced Options (app-level default): set value in enforcedOptions for the pathway. This guarantees the amount is always included, but it will waste lamports for recipients that already have an ATA. Use with caution in production.
  • Extra Options (per-transaction): set msg.value in extraOptions only when needed after checking whether the recipient’s ATA exists. This is the recommended approach to avoid unnecessary costs.

How much value to provide:

  • SPL Token accounts: the rent-exempt amount is 2_039_280 lamports (0.00203928 SOL).
  • Token-2022 accounts: the required value depends on the token account size, which varies by the enabled extensions. You can inspect the size of your token's token account and calculate the rent amount needed.

If setting via enforcedOptions in layerzero.config.ts, the parameter is value. If building per-transaction options in TypeScript, it is the second parameter to addExecutorLzReceiveOption(gas_limit, msg_value).

See the next section for how to detect ATA existence and attach msg.value conditionally via extraOptions.

info

For Solana OFTs that use Token2022, you will need to increase value to a higher amount, which depends on the token account size, which in turn depends on the extensions that you enable.

info

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 when the recipient’s ATA does not already exist.

For EVM → Solana sends, enforce the compute units ("gas") at the application level using enforcedOptions. When attaching per-transaction value for ATA creation via extraOptions, set gas to 0 and only provide msg.value as needed. The protocol combines extraOptions with your enforced baseline at execution time.

Conditional msg.value for ATA creation

For sends to Solana, you can avoid overpaying rent by setting your enforced options value to 0 and supplying msg.value only when the recipient’s Associated Token Account (ATA) is missing. This pattern is useful when recipients may or may not have an ATA for your mint.

Steps:

  • Set enforcedOptions value to 0 in layerzero.config.ts for pathways that deliver to Solana.
  • Before constructing extraOptions for a specific send to Solana, check if the recipient’s ATA exists.
  • If ATA exists: set msg.value = 0 in addExecutorLzReceiveOption.
  • If ATA does not exist: set msg.value to the rent-exempt minimum for the token account (e.g., 2_039_280 lamports for SPL; Token-2022 may require more depending on enabled extensions).

Example: check ATA existence using Umi and mpl-toolbox, then set options conditionally.

import {createUmi} from '@metaplex-foundation/umi-bundle-defaults';
import {findAssociatedTokenPda, safeFetchToken} from '@metaplex-foundation/mpl-toolbox';
import {publicKey} from '@metaplex-foundation/umi';
import {Options} from '@layerzerolabs/lz-v2-utilities';

const umi = createUmi('https://api.mainnet-beta.solana.com');

const mint = publicKey('<TOKEN_MINT>');
const owner = publicKey('<WALLET_OWNER>');

// derive ATA PDA
const ata = findAssociatedTokenPda(umi, {mint, owner});

// check if it exists
const account = await safeFetchToken(umi, ata);
if (account) {
console.log('ATA exists at', ata.toString());
} else {
console.log('ATA not found');
}

// set per-tx options based on ATA existence
// gas is enforced at app-level; set 0 here to avoid double-charging
const GAS_LIMIT = 0;
const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2039280; // rent-exempt lamports for SPL token account (ATA)
const MSG_VALUE = account ? 0 : SPL_TOKEN_ACCOUNT_RENT_VALUE; // if Token2022, use a higher value based on account size
const options = Options.newOptions().addExecutorLzReceiveOption(GAS_LIMIT, MSG_VALUE);
// rest of your code; calls to quote/send

Use options.toHex() (EVM) or options.toBytes() (Solana) when populating extraOptions/options in your send call. These values will be combined with any enforcedOptions configured at the app level. If your mint is Token-2022, compute the rent-exempt minimum from the token account size (varies by enabled extensions) and replace SPL_TOKEN_ACCOUNT_RENT_VALUE accordingly.

Precautions

One OFT Adapter per OFT deployment/mesh

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 pool'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.

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.

(Optional) Verify the OFT Program

To continue, you must first install solana-verify. You can learn about how program verification works in the official Solana program verification guide.

info

The commands given below assume that you did not make any modifications to the Solana OFT program source code. If you did, you can refer to the instructions in solana-verify directly.

Verification is done via the OtterSec API, which builds the program contained in the repo provided.

If you did not modify the OFT program, you can reference LayerZero's devtools repo, which removes the need for you to host your own public repo for verification purposes. By referencing LayerZero's devtools repo, you also benefit from the LayerZero OFT program's audited status.

Normally, each Anchor program requires its own repository for verification because the program ID provided to declare_id! is embedded in the bytecode, altering its hash. We solve this by having you supply the program ID as an environment variable during build time. This variable is then read by the program_id_from_env function in the OFT program's lib.rs snippet.

Below is the relevant code snippet:

declare_id!(Pubkey::new_from_array(program_id_from_env!(
"OFT_ID",
"9UovNrJD8pQyBLheeHNayuG1wJSEAoxkmM14vw5gcsTT"
)));

The above is used via providing OFT_ID as an environment variable when running solana-verify, which is demonstrated in the following sections.

Compare locally

If you wish to, you can view the program hash of the locally built OFT program:

solana-verify get-executable-hash ./target/verifiable/oft.so

Compare with the on-chain program hash:

solana-verify get-program-hash -u devnet <PROGRAM_ID>

Verify against a repository and submit verification data onchain

Run the following command to verify against the repo that contains the program source code:

solana-verify verify-from-repo -ud --program-id <PROGRAM_ID> --mount-path examples/oft-solana https://github.com/LayerZero-Labs/devtools --library-name oft -- --config env.OFT_ID=\'<PROGRAM_ID>\'

The above instruction runs against the Solana Devnet as it uses the -ud flag. To run it against Solana Mainnet, replace -ud with -um.

Upon successful verification, you will be prompted with the following:

Program hash matches ✅
Do you want to upload the program verification to the Solana Blockchain? (y/n)

Respond with y to proceed with uploading of the program verification data onchain.

(mainnet only) Submit to the OtterSec API

This will provide your program with the Verified status on explorers. Note that currently the Verified status only exists on mainnet explorers.

Verify against the code in the git repo and submit for verification status:

solana-verify verify-from-repo --remote -um --program-id <PROGRAM_ID> --mount-path examples/oft-solana https://github.com/LayerZero-Labs/devtools --library-name oft -- --config env.OFT_ID=\'<PROGRAM_ID>\'
note

You must run the above step using the same keypair as the program's upgrade authority. Learn more about the solana-verify CLI from the official repo.

caution

Program verification is tied to the program's Upgrade Authority. If you transfer a program's Upgrade Authority, you will need to redo the verification steps using the new Upgrade Authority address.

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.

Troubleshooting

DeclaredProgramIdMismatch

Full error: AnchorError occurred. Error Code: DeclaredProgramIdMismatch. Error Number: 4100. Error Message: The declared program id does not match the actual program id.

Fixing this error requires upgrading the deployed program.

caution

Upgrading your program will require that your keypair has sufficient SOL for the whole program's rent (approximately 3.9 SOL). This is due to how program upgrades in Solana works. Read further for the details. If you have access to additional SOL, we recommend you to continue with these steps. Alternatively, you can close the existing program account (which will return the current program's SOL rent) and deploy from scratch. Note that after closing a program account, you cannot reuse the same program ID, which means you must use a new program keypair.

This error occurs when the program is built with a declare_id! value that does not match its onchain program ID. The program ID onchain is determined by the original program keypair used when deploying (created by solana-keygen new -o target/deploy/endpoint-keypair.json --force).

To debug, check the following:

the following section in Anchor.toml:

[programs.localnet]
oft = "9obQfBnWMhxwYLtmarPWdjJgTc2mAYGRCoWbdvs9Wdm5"

the output of running anchor keys list:

endpoint: Cfego9Noyr78LWyYjz2rYUiaUR4L2XymJ6su8EpRUviU
oft: 9obQfBnWMhxwYLtmarPWdjJgTc2mAYGRCoWbdvs9Wdm5

Ensure that in both, the oft values match your OFT program's onchain ID.

If they already do, and you are still encountering DeclaredProgramIdMismatch, this means that you ran the build command with the wrong program ID, causing the declared program ID onchain to mismatch.

To fix this, you can re-run the build command, ensuring you pass in the OFT_ID env var:

anchor build -v -e OFT_ID=<OFT_PROGRAM_ID>

Then, re-deploy (upgrade) your program. For this step, your keypair is required to have sufficient SOL at least equivalent to current program's rent.

While the net difference in SOL will be zero if your program's size did not change, you will still need the same amount of SOL as required by the program's rent due to how Solana program upgrades work, which is as follows:

  • the existing program starts off as being unaffected
  • the updated program's bytecode is uploaded to a buffer account (new account, hence SOL for rent is required) which acts as a temporary staging area
  • the contents of the buffer account are then copied to the program data account
  • the buffer account is closed, and its rent SOL is returned

Run the deploy command to upgrade the program.

solana program deploy --program-id target/deploy/oft-keypair.json target/deploy/oft.so -u devnet --with-compute-unit-price 300000
info

To deploy to Solana Mainnet, replace -u devnet with -u mainnet-beta.

Retrying Failed Transactions

If a transaction fails, it may be due to network congestion or other temporary issues. You can retry the transaction by resubmitting it. Ensure that you have enough SOL in your account to cover the transaction fees.

Recovering Failed Rent

solana program close --buffer --keypair deployer-keypair.json -u mainnet-beta
note

For more troubleshooting help, refer to the Solana OFT README.

Building without Docker

Our default instructions ask you to build in verifiable mode:

anchor build -v -e OFT_ID=<OFT_PROGRAM_ID>

Where the -v flag instructs anchor to build in verifiable mode. We highly recommend you to build in verifiable mode, so that you can carry out program verification.

Verifiable mode requires Docker. If you cannot build using Docker, then the alternative is to build in regular mode, which results in slight differences in commands for two steps: build and deploy.

For building:

OFT_ID=<PROGRAM_ID> anchor build

In verifiable mode, the output defaults to target/verifiable/oft.so. In regular mode, the output defaults to target/deploy.oft.so.

For deploying:

solana program deploy --program-id target/deploy/oft-keypair.json target/deploy/oft.so -u devnet --with-compute-unit-price <COMPUTE_UNIT_PRICE_IN_MICRO_LAMPORTS>

All other commands remain the same.

Known Limitations

Max number of DVNs

Given Solana's transaction size limit of 1232 bytes, the current max number of DVNs for a pathway involving Solana is 5.

Token Extensions

While it is possible to create a Solana OFT using the Token2022 (Token Extensions) there is limited compatibility with token extensions. It is advised for you to conduct an end-to-end test if you require token extensions for your Solana OFT.

Cross Program Invocation into the OFT Program (CPI Depth limitation)

Solana has the max CPI Depth of 4. A Solana OFT send instruction has the following CPI trace:

OFT -> Endpoint -> ULN -> Worker -> Pricefeed

Which is already 4 CPI calls deep, relative to the OFT program.

caution

The above means it's not currently possible to CPI into the OFT program, as it would violate the current Solana CPI Depth limit of 4.

If you require a certain action to be taken in tandem with an OFT.send call, it would not be possible to have it be done in the same instruction. However, since Solana allows for multiple instructions per transaction, you can instead have it be grouped into the same transaction as the OFT.send instruction.

For example, if you have a project that involves staking OFTs cross-chain, and when unstaking (let's refer to this instruction as StakingProgram.unstake), you want to allow for the OFT to be sent (via OFT.send) to another chain in the same transaction, then you can do the following:

  • prepare the StakingProgram.unstake instruction
  • prepare the OFT.send instruction
  • submit both instructions in one transaction
caution

It would not be possible for you to have call OFT.send inside the StakingProgram's unstake instruction directly since this would result in the following CPI trace: StakingProgram -> OFT -> Endpoint -> ULN -> Worker -> Pricefeed, which has a CPI depth of 5, exceeding the limit of 4.