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.
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:
- Mint Authority and Freeze Authority
- Token Metadata
- Solana Account Model
- Solana Program Library and the Token-2022
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.
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.
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.
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 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. |
Mint Account | false | This 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 Multisig | false | A 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. |
Escrow | false | The 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 Store | false | A 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. |
PeerConfig | false | A 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. |
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.
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.
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.
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
inenforcedOptions
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
inextraOptions
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
.
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.
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 inlayerzero.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
inaddExecutorLzReceiveOption
. - 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:
- 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.
(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.
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>\'
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.
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
.
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.
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.
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
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
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.
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
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.