From 835e28e722a4735e0d24c0bafb558609f67ff6ab Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Tue, 26 Aug 2025 19:28:48 +0800 Subject: [PATCH 01/23] lz config --- examples/oft-solana/layerzero.config.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/examples/oft-solana/layerzero.config.ts b/examples/oft-solana/layerzero.config.ts index 8b500358e..8d06453c8 100644 --- a/examples/oft-solana/layerzero.config.ts +++ b/examples/oft-solana/layerzero.config.ts @@ -26,26 +26,31 @@ const EVM_ENFORCED_OPTIONS: OAppEnforcedOption[] = [ }, ] -const CU_LIMIT = 200000 // This represents the CU limit for executing the `lz_receive` function on Solana. -const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2039280 // This figure represents lamports (https://solana.com/docs/references/terminology#lamport) on Solana. Read below for more details. +const SOLANA_CU_LIMIT = 200_000 // This represents the CU limit for executing the `lz_receive` function on Solana. +// const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2039280 // This figure represents lamports (https://solana.com/docs/references/terminology#lamport) on Solana. Read below for more details. + /* - * Elaboration on `value` when sending OFTs to Solana: - * When sending OFTs to Solana, SOL is needed for rent (https://solana.com/docs/core/accounts#rent) to initialize the recipient's token account. - * The `2039280` lamports value is the exact rent value needed for SPL token accounts (0.00203928 SOL). - * For Token2022 token accounts, 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. + * Inbound to Solana enforced options: + * - Use gas equal to the CU limit needed for lz_receive. + * - Keep value=0 here; supply per-tx msg.value only when the recipient’s ATA + * must be created (~2,039,280 lamports for SPL; Token-2022 may require more). + * In this repo, per-tx value is set in ./tasks/evm/sendEvm.ts. + * Details: https://docs.layerzero.network/v2/developers/solana/oft/account#setting-enforced-options-inbound-to-solana */ const SOLANA_ENFORCED_OPTIONS: OAppEnforcedOption[] = [ { msgType: 1, optionType: ExecutorOptionType.LZ_RECEIVE, - gas: CU_LIMIT, - value: SPL_TOKEN_ACCOUNT_RENT_VALUE, + gas: SOLANA_CU_LIMIT, + // value: SPL_TOKEN_ACCOUNT_RENT_VALUE, // If you enable this, all sends regardless of whether the recipient already has the Associated Token Account will include the rent value, which might be wasteful. + value: 0, // value will be set where quote/send is called. In this example, it is set in ./tasks/evm/sendEvm.ts }, ] // Learn about Message Execution Options: https://docs.layerzero.network/v2/developers/solana/oft/account#message-execution-options // Learn more about the Simple Config Generator - https://docs.layerzero.network/v2/developers/evm/technical-reference/simple-config +// Learn about DVNs - https://docs.layerzero.network/v2/concepts/modular-security/security-stack-dvns export default async function () { // note: pathways declared here are automatically bidirectional // if you declare A,B there's no need to declare B,A From 4750bf7f4f85d2466229aac195d3649eb66e90c2 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:12:35 +0800 Subject: [PATCH 02/23] conditional value --- .changeset/two-cups-prove.md | 5 ++ examples/oft-solana/layerzero.config.ts | 2 +- examples/oft-solana/tasks/common/sendOFT.ts | 39 +++++++++++++-- examples/oft-solana/tasks/solana/utils.ts | 53 ++++++++++++++++++++- 4 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 .changeset/two-cups-prove.md diff --git a/.changeset/two-cups-prove.md b/.changeset/two-cups-prove.md new file mode 100644 index 000000000..7beaf6ba5 --- /dev/null +++ b/.changeset/two-cups-prove.md @@ -0,0 +1,5 @@ +--- +"@layerzerolabs/oft-solana-example": patch +--- + +for sends to Solana - conditional value based on ATA existence diff --git a/examples/oft-solana/layerzero.config.ts b/examples/oft-solana/layerzero.config.ts index 8d06453c8..c464f93a0 100644 --- a/examples/oft-solana/layerzero.config.ts +++ b/examples/oft-solana/layerzero.config.ts @@ -4,6 +4,7 @@ import { generateConnectionsConfig } from '@layerzerolabs/metadata-tools' import { OAppEnforcedOption, OmniPointHardhat } from '@layerzerolabs/toolbox-hardhat' import { getOftStoreAddress } from './tasks/solana' +// import { SPL_TOKEN_ACCOUNT_RENT_VALUE } from './tasks/solana/utils' // Note: Do not use address for EVM OmniPointHardhat contracts. Contracts are loaded using hardhat-deploy. // If you do use an address, ensure artifacts exists. @@ -27,7 +28,6 @@ const EVM_ENFORCED_OPTIONS: OAppEnforcedOption[] = [ ] const SOLANA_CU_LIMIT = 200_000 // This represents the CU limit for executing the `lz_receive` function on Solana. -// const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2039280 // This figure represents lamports (https://solana.com/docs/references/terminology#lamport) on Solana. Read below for more details. /* * Inbound to Solana enforced options: diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index 642ac1e44..7372e4cf8 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -2,9 +2,12 @@ import { task, types } from 'hardhat/config' import { HardhatRuntimeEnvironment } from 'hardhat/types' import { ChainType, endpointIdToChainType, endpointIdToNetwork } from '@layerzerolabs/lz-definitions' +import { Options } from '@layerzerolabs/lz-v2-utilities' import { EvmArgs, sendEvm } from '../evm/sendEvm' +import { getSolanaDeployment } from '../solana' import { SolanaArgs, sendSolana } from '../solana/sendSolana' +import { SPL_TOKEN_ACCOUNT_RENT_VALUE, SolanaTokenType, checkAssociatedTokenAccountExists } from '../solana/utils' import { SendResult } from './types' import { DebugLogger, KnownOutputs, KnownWarnings, getBlockExplorerLink } from './utils' @@ -55,7 +58,8 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') .addOptionalParam('tokenProgram', 'Solana Token Program pubkey', undefined, types.string) .addOptionalParam('computeUnitPriceScaleFactor', 'Solana compute unit price scale factor', 4, types.float) .setAction(async (args: MasterArgs, hre: HardhatRuntimeEnvironment) => { - const chainType = endpointIdToChainType(args.srcEid) + const srcChainType = endpointIdToChainType(args.srcEid) + const dstChainType = endpointIdToChainType(args.dstEid) let result: SendResult if (args.oftAddress || args.oftProgramId) { @@ -65,13 +69,38 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') ) } - // route to the correct function based on the chain type - if (chainType === ChainType.EVM) { + let conditionalValue = 0 + // if sending to Solana, check if the recipient already has an associated token account + // refer to https://docs.layerzero.network/v2/developers/solana/oft/account#setting-enforced-options-inbound-to-solana + if (dstChainType === ChainType.SOLANA) { + const solanaDeployment = getSolanaDeployment(args.dstEid) + const recipient = args.to + const { ataExists, tokenType } = await checkAssociatedTokenAccountExists({ + eid: args.dstEid, + mint: solanaDeployment.mint, + owner: recipient, + }) + if (!ataExists && tokenType === SolanaTokenType.SPL) { + conditionalValue = SPL_TOKEN_ACCOUNT_RENT_VALUE + } + } + // throw if user specified extraOptions and we also need to set conditionalValue + if (args.extraOptions && conditionalValue > 0) { + throw new Error('extraOptions and conditionalValue cannot be set at the same time') + // hint: do not pass in extraOptions + } + // if there's conditionalValue, we build the extraOptions + if (conditionalValue > 0) { + args.extraOptions = Options.newOptions().addExecutorLzReceiveOption(0, conditionalValue).toHex() + } + + // route to the correct send function based on the source chain type + if (srcChainType === ChainType.EVM) { result = await sendEvm(args as EvmArgs, hre) - } else if (chainType === ChainType.SOLANA) { + } else if (srcChainType === ChainType.SOLANA) { result = await sendSolana(args as SolanaArgs) } else { - throw new Error(`The chain type ${chainType} is not implemented in sendOFT for this example`) + throw new Error(`The chain type ${srcChainType} is not implemented in sendOFT for this example`) } DebugLogger.printLayerZeroOutput( diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index fd50673af..4249b7036 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -1,3 +1,6 @@ +import { findAssociatedTokenPda, safeFetchMint, safeFetchToken } from '@metaplex-foundation/mpl-toolbox' +import { PublicKey, Umi, publicKey } from '@metaplex-foundation/umi' +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' import { Connection } from '@solana/web3.js' import { HardhatRuntimeEnvironment } from 'hardhat/types' @@ -10,6 +13,10 @@ import { TASK_LZ_OAPP_CONFIG_GET, } from '@layerzerolabs/ua-devtools-evm-hardhat' +import { deriveConnection } from './index' + +export const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2_039_280 // This figure represents lamports (https://solana.com/docs/references/terminology#lamport) on Solana. Read below for more details. + export const findSolanaEndpointIdInGraph = async ( hre: HardhatRuntimeEnvironment, oappConfig: string @@ -72,8 +79,9 @@ export function parseDecimalToUnits(amount: string, decimals: number): bigint { * that mentions the 429 retry. */ export function silenceSolana429(connection: Connection): void { - const origWrite = process.stderr.write.bind(process.stderr) - process.stderr.write = ((chunk: any, ...args: any[]) => { + type WriteFn = (chunk: string | Buffer, ...args: unknown[]) => boolean + const origWrite = process.stderr.write.bind(process.stderr) as WriteFn + process.stderr.write = ((chunk: string | Buffer, ...args: unknown[]) => { const str = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk if (typeof str === 'string' && str.includes('429 Too Many Requests')) { // swallow it @@ -83,3 +91,44 @@ export function silenceSolana429(connection: Connection): void { return origWrite(chunk, ...args) }) as typeof process.stderr.write } + +export enum SolanaTokenType { + SPL = 'spl', + TOKEN2022 = 'token2022', +} + +/** + * Check if an Associated Token Account (ATA) exists for a given mint and owner. + * Returns the derived ATA and a boolean indicating existence. + */ +export async function checkAssociatedTokenAccountExists(args: { + umi?: Umi + eid: EndpointId + mint: PublicKey | string + owner: PublicKey | string +}): Promise<{ ata: string; ataExists: boolean; tokenType: SolanaTokenType | null }> { + const { umi: providedUmi, eid, mint, owner } = args + const umi = providedUmi ?? (await deriveConnection(eid, true)).umi + + const mintPk = typeof mint === 'string' ? publicKey(mint) : mint + const ownerPk = typeof owner === 'string' ? publicKey(owner) : owner + + const ata = findAssociatedTokenPda(umi, { mint: mintPk, owner: ownerPk }) + const account = await safeFetchToken(umi, ata) + const mintAccount = await safeFetchMint(umi, mintPk) + // check header.owner to determine if the token is SPL or Token2022 using switch + let tokenType: SolanaTokenType | null = null + + switch (mintAccount?.header.owner) { + case TOKEN_PROGRAM_ID.toBase58(): + tokenType = SolanaTokenType.SPL + break + case TOKEN_2022_PROGRAM_ID.toBase58(): + tokenType = SolanaTokenType.TOKEN2022 + break + default: + throw new Error(`Unknown token type: ${account?.header.owner}`) + } + + return { ata: ata[0], ataExists: !!account, tokenType } +} From 28afa3d9f6e89eb800f3875eb494e628af160904 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:20:48 +0800 Subject: [PATCH 03/23] insert comment --- examples/oft-solana/tasks/common/sendOFT.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index 7372e4cf8..a8c30ad15 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -75,15 +75,20 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') if (dstChainType === ChainType.SOLANA) { const solanaDeployment = getSolanaDeployment(args.dstEid) const recipient = args.to + // note that there may still exist a race condition + // if the first cross-chain send to a Solana recipient has not been executed yet, and a second send is initiated + // then the second send will still attach the rent value since the ATA does not exist yet const { ataExists, tokenType } = await checkAssociatedTokenAccountExists({ eid: args.dstEid, mint: solanaDeployment.mint, owner: recipient, }) + if (!ataExists && tokenType === SolanaTokenType.SPL) { conditionalValue = SPL_TOKEN_ACCOUNT_RENT_VALUE } } + // throw if user specified extraOptions and we also need to set conditionalValue if (args.extraOptions && conditionalValue > 0) { throw new Error('extraOptions and conditionalValue cannot be set at the same time') From 5e425ac917c23d3aa1333f45e76642922d5b8a37 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:25:34 +0800 Subject: [PATCH 04/23] comments --- examples/oft-solana/tasks/common/sendOFT.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index a8c30ad15..c67d2d651 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -69,6 +69,8 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') ) } + // NOTE: the conditionalValue block below assumes that in layerzeroconfig.ts, in the SOLANA_ENFORCED_OPTIONS, you have set the value to 0 + // Setting value both in the SOLANA_ENFORCED_OPTIONS and in the conditionalValue block below will result in redundant value being sent let conditionalValue = 0 // if sending to Solana, check if the recipient already has an associated token account // refer to https://docs.layerzero.network/v2/developers/solana/oft/account#setting-enforced-options-inbound-to-solana @@ -92,7 +94,7 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') // throw if user specified extraOptions and we also need to set conditionalValue if (args.extraOptions && conditionalValue > 0) { throw new Error('extraOptions and conditionalValue cannot be set at the same time') - // hint: do not pass in extraOptions + // hint: do not pass in extraOptions via params } // if there's conditionalValue, we build the extraOptions if (conditionalValue > 0) { From dcb0e5fd4e8a6a8ccd25a191b87cddb0c6c16354 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:26:48 +0800 Subject: [PATCH 05/23] comment --- examples/oft-solana/tasks/common/sendOFT.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index c67d2d651..524bd8e42 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -91,12 +91,12 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') } } - // throw if user specified extraOptions and we also need to set conditionalValue + // throw if user specified extraOptions and conditionalValue is non-zero if (args.extraOptions && conditionalValue > 0) { throw new Error('extraOptions and conditionalValue cannot be set at the same time') // hint: do not pass in extraOptions via params } - // if there's conditionalValue, we build the extraOptions + // if there's conditionalValue, we build the extraOptions to be passed in if (conditionalValue > 0) { args.extraOptions = Options.newOptions().addExecutorLzReceiveOption(0, conditionalValue).toHex() } From 8967bb900d6cae77d5f7ea12faafa881b50b30fa Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:32:20 +0800 Subject: [PATCH 06/23] extract helpoer --- examples/oft-solana/tasks/common/sendOFT.ts | 17 +++--------- examples/oft-solana/tasks/solana/utils.ts | 30 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index 524bd8e42..accc26e63 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -7,7 +7,7 @@ import { Options } from '@layerzerolabs/lz-v2-utilities' import { EvmArgs, sendEvm } from '../evm/sendEvm' import { getSolanaDeployment } from '../solana' import { SolanaArgs, sendSolana } from '../solana/sendSolana' -import { SPL_TOKEN_ACCOUNT_RENT_VALUE, SolanaTokenType, checkAssociatedTokenAccountExists } from '../solana/utils' +import { getConditionalValueForSendToSolana } from '../solana/utils' import { SendResult } from './types' import { DebugLogger, KnownOutputs, KnownWarnings, getBlockExplorerLink } from './utils' @@ -72,23 +72,14 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') // NOTE: the conditionalValue block below assumes that in layerzeroconfig.ts, in the SOLANA_ENFORCED_OPTIONS, you have set the value to 0 // Setting value both in the SOLANA_ENFORCED_OPTIONS and in the conditionalValue block below will result in redundant value being sent let conditionalValue = 0 - // if sending to Solana, check if the recipient already has an associated token account - // refer to https://docs.layerzero.network/v2/developers/solana/oft/account#setting-enforced-options-inbound-to-solana + // If sending to Solana, compute conditional value for ATA creation if (dstChainType === ChainType.SOLANA) { const solanaDeployment = getSolanaDeployment(args.dstEid) - const recipient = args.to - // note that there may still exist a race condition - // if the first cross-chain send to a Solana recipient has not been executed yet, and a second send is initiated - // then the second send will still attach the rent value since the ATA does not exist yet - const { ataExists, tokenType } = await checkAssociatedTokenAccountExists({ + conditionalValue = await getConditionalValueForSendToSolana({ eid: args.dstEid, + recipient: args.to, mint: solanaDeployment.mint, - owner: recipient, }) - - if (!ataExists && tokenType === SolanaTokenType.SPL) { - conditionalValue = SPL_TOKEN_ACCOUNT_RENT_VALUE - } } // throw if user specified extraOptions and conditionalValue is non-zero diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index 4249b7036..a7b0ced0a 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -16,6 +16,7 @@ import { import { deriveConnection } from './index' export const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2_039_280 // This figure represents lamports (https://solana.com/docs/references/terminology#lamport) on Solana. Read below for more details. +export const TOKEN_2022_ACCOUNT_RENT_VALUE = 2_500_000 // NOTE: The actual value needed depends on which extensions are enabled. You would need to determine this value based on your own Token2022 token. export const findSolanaEndpointIdInGraph = async ( hre: HardhatRuntimeEnvironment, @@ -132,3 +133,32 @@ export async function checkAssociatedTokenAccountExists(args: { return { ata: ata[0], ataExists: !!account, tokenType } } + +/** + * Compute the per-transaction msg.value to attach when sending to Solana. + * Returns 0 if the recipient ATA already exists or if the mint is Token2022. + * Returns SPL_TOKEN_ACCOUNT_RENT_VALUE if the recipient ATA is missing and the mint is SPL. + */ +export async function getConditionalValueForSendToSolana(args: { + eid: EndpointId + recipient: string + mint: string | PublicKey + umi?: Umi +}): Promise { + const { eid, recipient, mint, umi } = args + const { ataExists, tokenType } = await checkAssociatedTokenAccountExists({ + eid, + owner: recipient, + mint, + umi, + }) + if (!ataExists && tokenType === SolanaTokenType.SPL) { + return SPL_TOKEN_ACCOUNT_RENT_VALUE + } else if (!ataExists && tokenType === SolanaTokenType.TOKEN2022) { + console.warn( + 'Ensure that the TOKEN_2022_ACCOUNT_RENT_VALUE has been updated according to your actual token account size' + ) + return TOKEN_2022_ACCOUNT_RENT_VALUE + } + return 0 +} From 986e4c9ccc0de468943eb655e4a10e37c612f02e Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:33:02 +0800 Subject: [PATCH 07/23] fix --- examples/oft-solana/layerzero.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/oft-solana/layerzero.config.ts b/examples/oft-solana/layerzero.config.ts index c464f93a0..403a6ef1e 100644 --- a/examples/oft-solana/layerzero.config.ts +++ b/examples/oft-solana/layerzero.config.ts @@ -44,7 +44,7 @@ const SOLANA_ENFORCED_OPTIONS: OAppEnforcedOption[] = [ optionType: ExecutorOptionType.LZ_RECEIVE, gas: SOLANA_CU_LIMIT, // value: SPL_TOKEN_ACCOUNT_RENT_VALUE, // If you enable this, all sends regardless of whether the recipient already has the Associated Token Account will include the rent value, which might be wasteful. - value: 0, // value will be set where quote/send is called. In this example, it is set in ./tasks/evm/sendEvm.ts + value: 0, // value will be set where quote/send is called. In this example, it is set in ./tasks/common/sendOFT.ts }, ] From e2f85746ff45132b5ed583a7843f5bcbb5d2cd47 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:33:50 +0800 Subject: [PATCH 08/23] elab --- examples/oft-solana/tasks/solana/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index a7b0ced0a..db46479ac 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -16,7 +16,7 @@ import { import { deriveConnection } from './index' export const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2_039_280 // This figure represents lamports (https://solana.com/docs/references/terminology#lamport) on Solana. Read below for more details. -export const TOKEN_2022_ACCOUNT_RENT_VALUE = 2_500_000 // NOTE: The actual value needed depends on which extensions are enabled. You would need to determine this value based on your own Token2022 token. +export const TOKEN_2022_ACCOUNT_RENT_VALUE = 2_500_000 // NOTE: The actual value needed depends on which specific extensions you have enabled for your Token2022 token. You would need to determine this value based on your own Token2022 token. export const findSolanaEndpointIdInGraph = async ( hre: HardhatRuntimeEnvironment, From 2b4d2bb126139dcaa7930c40d8214169cfc8f5b7 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Thu, 28 Aug 2025 23:42:16 +0800 Subject: [PATCH 09/23] fix --- examples/oft-solana/tasks/solana/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index db46479ac..de8389541 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -128,7 +128,7 @@ export async function checkAssociatedTokenAccountExists(args: { tokenType = SolanaTokenType.TOKEN2022 break default: - throw new Error(`Unknown token type: ${account?.header.owner}`) + throw new Error(`Unknown token type: ${mintAccount?.header.owner}`) } return { ata: ata[0], ataExists: !!account, tokenType } From 92c9349065a9a187c1a83069daebfd13516246b5 Mon Sep 17 00:00:00 2001 From: Nazreen <10964594+nazreen@users.noreply.github.com> Date: Thu, 28 Aug 2025 23:43:41 +0800 Subject: [PATCH 10/23] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/oft-solana/tasks/common/sendOFT.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index accc26e63..932128596 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -84,7 +84,7 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') // throw if user specified extraOptions and conditionalValue is non-zero if (args.extraOptions && conditionalValue > 0) { - throw new Error('extraOptions and conditionalValue cannot be set at the same time') + throw new Error('Cannot set extraOptions when conditional value is required for ATA creation. Remove extraOptions parameter to allow automatic value calculation.') // hint: do not pass in extraOptions via params } // if there's conditionalValue, we build the extraOptions to be passed in From 3217bd703fce832827d43e60090474ed93fe5875 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Fri, 29 Aug 2025 01:26:26 +0800 Subject: [PATCH 11/23] improve --- examples/oft-solana/tasks/common/sendOFT.ts | 31 +++++------ examples/oft-solana/tasks/evm/sendEvm.ts | 59 ++++++++++++++++----- examples/oft-solana/tasks/solana/utils.ts | 55 ++++++++++++------- 3 files changed, 98 insertions(+), 47 deletions(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index 932128596..588962a62 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -2,12 +2,11 @@ import { task, types } from 'hardhat/config' import { HardhatRuntimeEnvironment } from 'hardhat/types' import { ChainType, endpointIdToChainType, endpointIdToNetwork } from '@layerzerolabs/lz-definitions' -import { Options } from '@layerzerolabs/lz-v2-utilities' import { EvmArgs, sendEvm } from '../evm/sendEvm' import { getSolanaDeployment } from '../solana' import { SolanaArgs, sendSolana } from '../solana/sendSolana' -import { getConditionalValueForSendToSolana } from '../solana/utils' +import { getMinimumValueForSendToSolana } from '../solana/utils' import { SendResult } from './types' import { DebugLogger, KnownOutputs, KnownWarnings, getBlockExplorerLink } from './utils' @@ -25,10 +24,14 @@ interface MasterArgs { composeMsg?: string /** EVM: 20-byte hex; Solana: base58 PDA of the store */ oftAddress?: string + /** EVM: 20-byte hex; Solana: base58 PDA of the store (currently only relevant for sends to Solana) */ + dstOftAddress?: string /** Solana only: override the OFT program ID (base58) */ oftProgramId?: string tokenProgram?: string computeUnitPriceScaleFactor?: number + /** Solana only (so far): minimum value needed successful lzReceive on the destination chain */ + minimumLzReceiveValue?: number } task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') @@ -54,6 +57,12 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') undefined, types.string ) + .addOptionalParam( + 'dstOftAddress', + 'Override the destination local deployment OFT address (20-byte hex for EVM, base58 PDA for Solana)', + undefined, + types.string + ) .addOptionalParam('oftProgramId', 'Solana only: override the OFT program ID (base58)', undefined, types.string) .addOptionalParam('tokenProgram', 'Solana Token Program pubkey', undefined, types.string) .addOptionalParam('computeUnitPriceScaleFactor', 'Solana compute unit price scale factor', 4, types.float) @@ -71,25 +80,17 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') // NOTE: the conditionalValue block below assumes that in layerzeroconfig.ts, in the SOLANA_ENFORCED_OPTIONS, you have set the value to 0 // Setting value both in the SOLANA_ENFORCED_OPTIONS and in the conditionalValue block below will result in redundant value being sent - let conditionalValue = 0 + let minimumLzReceiveValue = 0 // If sending to Solana, compute conditional value for ATA creation if (dstChainType === ChainType.SOLANA) { const solanaDeployment = getSolanaDeployment(args.dstEid) - conditionalValue = await getConditionalValueForSendToSolana({ + // determines the absolute minimum value needed for an OFT send to Solana (based on ATA creation status) + minimumLzReceiveValue = await getMinimumValueForSendToSolana({ eid: args.dstEid, recipient: args.to, - mint: solanaDeployment.mint, + mint: args.dstOftAddress || solanaDeployment.mint, }) - } - - // throw if user specified extraOptions and conditionalValue is non-zero - if (args.extraOptions && conditionalValue > 0) { - throw new Error('Cannot set extraOptions when conditional value is required for ATA creation. Remove extraOptions parameter to allow automatic value calculation.') - // hint: do not pass in extraOptions via params - } - // if there's conditionalValue, we build the extraOptions to be passed in - if (conditionalValue > 0) { - args.extraOptions = Options.newOptions().addExecutorLzReceiveOption(0, conditionalValue).toHex() + args.minimumLzReceiveValue = minimumLzReceiveValue } // route to the correct send function based on the source chain type diff --git a/examples/oft-solana/tasks/evm/sendEvm.ts b/examples/oft-solana/tasks/evm/sendEvm.ts index b5bddd505..f21a04073 100644 --- a/examples/oft-solana/tasks/evm/sendEvm.ts +++ b/examples/oft-solana/tasks/evm/sendEvm.ts @@ -7,6 +7,7 @@ import { makeBytes32 } from '@layerzerolabs/devtools' import { createGetHreByEid } from '@layerzerolabs/devtools-evm-hardhat' import { createLogger, promptToContinue } from '@layerzerolabs/io-devtools' import { ChainType, endpointIdToChainType, endpointIdToNetwork } from '@layerzerolabs/lz-definitions' +import { Options } from '@layerzerolabs/lz-v2-utilities' import layerzeroConfig from '../../layerzero.config' import { SendResult } from '../common/types' @@ -22,9 +23,10 @@ export interface EvmArgs { extraOptions?: string composeMsg?: string oftAddress?: string + minimumLzReceiveValue?: number } export async function sendEvm( - { srcEid, dstEid, amount, to, minAmount, extraOptions, composeMsg, oftAddress }: EvmArgs, + { srcEid, dstEid, amount, to, minAmount, extraOptions, composeMsg, oftAddress, minimumLzReceiveValue }: EvmArgs, hre: HardhatRuntimeEnvironment ): Promise { if (endpointIdToChainType(srcEid) !== ChainType.EVM) { @@ -41,6 +43,7 @@ export async function sendEvm( ) throw error } + const signer = await srcEidHre.ethers.getNamedSigner('deployer') // 1️⃣ resolve the OFT wrapper address let wrapperAddress: string @@ -74,22 +77,14 @@ export async function sendEvm( // hex string → Uint8Array → zero-pad to 32 bytes toBytes = makeBytes32(to) } - // 6️⃣ build sendParam and dispatch - const sendParam = { - dstEid, - to: toBytes, - amountLD: amountUnits.toString(), - minAmountLD: minAmount ? parseUnits(minAmount, decimals).toString() : amountUnits.toString(), - extraOptions: extraOptions ? extraOptions.toString() : '0x', - composeMsg: composeMsg ? composeMsg.toString() : '0x', - oftCmd: '0x', - } - // Check whether there are extra options or enforced options. If not, warn the user. + let enforcedOptions = '0x' + + // BOF: Check whether there are extra options or enforced options. If not, warn the user. // Read on Message Options: https://docs.layerzero.network/v2/concepts/message-options - if (!extraOptions) { + if (!extraOptions || extraOptions === '0x') { try { - const enforcedOptions = composeMsg + enforcedOptions = composeMsg ? await oft.enforcedOptions(dstEid, MSG_TYPE.SEND_AND_CALL) : await oft.enforcedOptions(dstEid, MSG_TYPE.SEND) @@ -105,6 +100,42 @@ export async function sendEvm( logger.debug(`Failed to check enforced options: ${error}`) } } + // EOF: Check whether there are extra options or enforced options. If not, warn the user. + + // BOF: evaluate whether options require additional value + // There's no Typescript function for combining options, so we'll decode both enforcedOptions and extraOptions to get their values + const enforcedOptionsValue = Options.fromOptions(enforcedOptions).decodeExecutorLzReceiveOption()?.value ?? 0n + const extraOptionsGas = extraOptions + ? Options.fromOptions(extraOptions).decodeExecutorLzReceiveOption()?.gas ?? 0n + : 0n + const extraOptionsValue = extraOptions + ? Options.fromOptions(extraOptions).decodeExecutorLzReceiveOption()?.value ?? 0n + : 0n + const totalOptionsValue = enforcedOptionsValue + extraOptionsValue + let valueShortfall = 0n + // if minimumLzReceiveValue is greater than totalOptionsValue, we need to add the difference to the amount + if (minimumLzReceiveValue && BigInt(minimumLzReceiveValue) > totalOptionsValue) { + console.info( + `minimum lzReceive value needed is greater than the total options value, adding extraOptions to cover the difference: ${minimumLzReceiveValue} (minimum) - ${totalOptionsValue} (total) = ${valueShortfall} (shortfall)` + ) + valueShortfall = BigInt(minimumLzReceiveValue) - totalOptionsValue + } + if (valueShortfall > 0n) { + // if there's a value shortfall, we add the difference as extraOptions + extraOptions = Options.newOptions().addExecutorLzReceiveOption(extraOptionsGas, valueShortfall).toHex() + } + // EOF: evaluate whether options require additional value + process.exit(0) + // 6️⃣ build sendParam and dispatch + const sendParam = { + dstEid, + to: toBytes, + amountLD: amountUnits.toString(), + minAmountLD: minAmount ? parseUnits(minAmount, decimals).toString() : amountUnits.toString(), + extraOptions: extraOptions ? extraOptions.toString() : '0x', + composeMsg: composeMsg ? composeMsg.toString() : '0x', + oftCmd: '0x', + } // 6️⃣ Quote (MessagingFee = { nativeFee, lzTokenFee }) logger.info('Quoting the native gas cost for the send transaction...') diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index de8389541..cd8fb0475 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -113,52 +113,71 @@ export async function checkAssociatedTokenAccountExists(args: { const mintPk = typeof mint === 'string' ? publicKey(mint) : mint const ownerPk = typeof owner === 'string' ? publicKey(owner) : owner - - const ata = findAssociatedTokenPda(umi, { mint: mintPk, owner: ownerPk }) - const account = await safeFetchToken(umi, ata) const mintAccount = await safeFetchMint(umi, mintPk) - // check header.owner to determine if the token is SPL or Token2022 using switch - let tokenType: SolanaTokenType | null = null + if (!mintAccount) throw new Error(`Mint not found: ${mintPk}`) - switch (mintAccount?.header.owner) { + let tokenType: SolanaTokenType + let tokenProgramId: string + switch (mintAccount.header.owner) { case TOKEN_PROGRAM_ID.toBase58(): tokenType = SolanaTokenType.SPL + tokenProgramId = TOKEN_PROGRAM_ID.toBase58() break case TOKEN_2022_PROGRAM_ID.toBase58(): tokenType = SolanaTokenType.TOKEN2022 + tokenProgramId = TOKEN_2022_PROGRAM_ID.toBase58() break default: - throw new Error(`Unknown token type: ${mintAccount?.header.owner}`) + throw new Error(`Unknown token program: ${mintAccount.header.owner}`) } - return { ata: ata[0], ataExists: !!account, tokenType } + // Derive ATA with the matching token program id. + const ataPda = findAssociatedTokenPda(umi, { + mint: mintPk, + owner: ownerPk, + tokenProgramId: publicKey(tokenProgramId), + }) + + const ataPk = ataPda[0] + const account = await safeFetchToken(umi, ataPk) + + return { ata: ataPk, ataExists: !!account, tokenType } } /** - * Compute the per-transaction msg.value to attach when sending to Solana. + * Compute the minimum required per-transaction msg.value to attach when sending to Solana. * Returns 0 if the recipient ATA already exists or if the mint is Token2022. * Returns SPL_TOKEN_ACCOUNT_RENT_VALUE if the recipient ATA is missing and the mint is SPL. */ -export async function getConditionalValueForSendToSolana(args: { +export async function getMinimumValueForSendToSolana(args: { eid: EndpointId recipient: string mint: string | PublicKey umi?: Umi }): Promise { const { eid, recipient, mint, umi } = args - const { ataExists, tokenType } = await checkAssociatedTokenAccountExists({ + // Note that there may still exist a race condition and stale RPC data issue + // Race Condition 1: First send to address X on Solana is still in flight, and the second send to address X on Solana is initiated. The second send would evaluate the ATA as not yet created. + // Stale RPC data issue: The ATA might have been created at t=0, but the RPC will only pick it up at t=X but a send was initiated at t < x. + const { ata, ataExists, tokenType } = await checkAssociatedTokenAccountExists({ eid, owner: recipient, mint, umi, }) - if (!ataExists && tokenType === SolanaTokenType.SPL) { - return SPL_TOKEN_ACCOUNT_RENT_VALUE - } else if (!ataExists && tokenType === SolanaTokenType.TOKEN2022) { - console.warn( - 'Ensure that the TOKEN_2022_ACCOUNT_RENT_VALUE has been updated according to your actual token account size' - ) - return TOKEN_2022_ACCOUNT_RENT_VALUE + console.info(`ATA: ${ata}, ATA exists: ${ataExists}, tokenType: ${tokenType}`) + if (!ataExists) { + // if the ATA does not exist, we return the minimum value needed for the ATA creation + if (tokenType === SolanaTokenType.SPL) { + console.info('ATA does not exist for the recipient and mint is SPL') + return SPL_TOKEN_ACCOUNT_RENT_VALUE + } else if (tokenType === SolanaTokenType.TOKEN2022) { + console.warn('Ensure that TOKEN_2022_ACCOUNT_RENT_VALUE matches your token account size') + return TOKEN_2022_ACCOUNT_RENT_VALUE + } + } else { + // if the ATA exists, we return 0 + return 0 } return 0 } From 4de871165ea9c182e4834789505414adaf7238c2 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Fri, 29 Aug 2025 01:30:05 +0800 Subject: [PATCH 12/23] rm --- examples/oft-solana/tasks/evm/sendEvm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/oft-solana/tasks/evm/sendEvm.ts b/examples/oft-solana/tasks/evm/sendEvm.ts index f21a04073..4f2ad0f3a 100644 --- a/examples/oft-solana/tasks/evm/sendEvm.ts +++ b/examples/oft-solana/tasks/evm/sendEvm.ts @@ -125,7 +125,7 @@ export async function sendEvm( extraOptions = Options.newOptions().addExecutorLzReceiveOption(extraOptionsGas, valueShortfall).toHex() } // EOF: evaluate whether options require additional value - process.exit(0) + // 6️⃣ build sendParam and dispatch const sendParam = { dstEid, From 246f5a1f5cc4e55368a34ffd128633e0362e22e5 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Fri, 29 Aug 2025 01:31:47 +0800 Subject: [PATCH 13/23] cm --- examples/oft-solana/tasks/common/sendOFT.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index 588962a62..ddf639336 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -78,8 +78,6 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') ) } - // NOTE: the conditionalValue block below assumes that in layerzeroconfig.ts, in the SOLANA_ENFORCED_OPTIONS, you have set the value to 0 - // Setting value both in the SOLANA_ENFORCED_OPTIONS and in the conditionalValue block below will result in redundant value being sent let minimumLzReceiveValue = 0 // If sending to Solana, compute conditional value for ATA creation if (dstChainType === ChainType.SOLANA) { From 66adc77ff92c12efa5b031356ac8f75d46af0ffd Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Fri, 29 Aug 2025 01:35:31 +0800 Subject: [PATCH 14/23] comment --- examples/oft-solana/tasks/common/sendOFT.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index ddf639336..c6b3221f2 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -79,7 +79,7 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') } let minimumLzReceiveValue = 0 - // If sending to Solana, compute conditional value for ATA creation + // If sending to Solana, compute minimum value needed for ATA creation if (dstChainType === ChainType.SOLANA) { const solanaDeployment = getSolanaDeployment(args.dstEid) // determines the absolute minimum value needed for an OFT send to Solana (based on ATA creation status) From 6b089da5981dcc9eab57e088eb579afb6a0eeb53 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Fri, 29 Aug 2025 01:43:31 +0800 Subject: [PATCH 15/23] refactor --- examples/oft-solana/tasks/common/utils.ts | 30 +++++++++++++++++++++++ examples/oft-solana/tasks/evm/sendEvm.ts | 27 ++++++-------------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/examples/oft-solana/tasks/common/utils.ts b/examples/oft-solana/tasks/common/utils.ts index 91dd953c3..28d493c3b 100644 --- a/examples/oft-solana/tasks/common/utils.ts +++ b/examples/oft-solana/tasks/common/utils.ts @@ -126,6 +126,36 @@ export function decodeLzReceiveOptions(hex: string): string { } } +export function calculateGasAndValueShortfall( + enforcedOptions: string, + extraOptions?: string, + minimumValue?: number | bigint +): { gasShortfall: bigint; valueShortfall: bigint } { + // There's no Typescript function for combining options, so we'll decode both enforcedOptions and extraOptions to get their values + const enforcedOptionsValue = Options.fromOptions(enforcedOptions).decodeExecutorLzReceiveOption()?.value ?? 0n + const extraOptionsGas = extraOptions + ? Options.fromOptions(extraOptions).decodeExecutorLzReceiveOption()?.gas ?? 0n + : 0n + const extraOptionsValue = extraOptions + ? Options.fromOptions(extraOptions).decodeExecutorLzReceiveOption()?.value ?? 0n + : 0n + const totalOptionsValue = enforcedOptionsValue + extraOptionsValue + + if (minimumValue === undefined || minimumValue === null) { + return { gasShortfall: extraOptionsGas, valueShortfall: 0n } + } + + const minimum = BigInt(minimumValue) + if (minimum > totalOptionsValue) { + const shortfall = minimum - totalOptionsValue + console.info( + `minimum lzReceive value needed is greater than the total options value, adding extraOptions to cover the difference: ${minimum} (minimum) - ${totalOptionsValue} (total) = ${shortfall} (shortfall)` + ) + return { gasShortfall: extraOptionsGas, valueShortfall: shortfall } + } + return { gasShortfall: extraOptionsGas, valueShortfall: 0n } +} + export async function getSolanaUlnConfigPDAs( remote: EndpointId, connection: Connection, diff --git a/examples/oft-solana/tasks/evm/sendEvm.ts b/examples/oft-solana/tasks/evm/sendEvm.ts index 4f2ad0f3a..8371e000e 100644 --- a/examples/oft-solana/tasks/evm/sendEvm.ts +++ b/examples/oft-solana/tasks/evm/sendEvm.ts @@ -11,8 +11,9 @@ import { Options } from '@layerzerolabs/lz-v2-utilities' import layerzeroConfig from '../../layerzero.config' import { SendResult } from '../common/types' -import { DebugLogger, KnownErrors, MSG_TYPE, isEmptyOptionsEvm } from '../common/utils' +import { DebugLogger, KnownErrors, MSG_TYPE, calculateGasAndValueShortfall, isEmptyOptionsEvm } from '../common/utils' import { getLayerZeroScanLink } from '../solana' + const logger = createLogger() export interface EvmArgs { srcEid: number @@ -103,26 +104,14 @@ export async function sendEvm( // EOF: Check whether there are extra options or enforced options. If not, warn the user. // BOF: evaluate whether options require additional value - // There's no Typescript function for combining options, so we'll decode both enforcedOptions and extraOptions to get their values - const enforcedOptionsValue = Options.fromOptions(enforcedOptions).decodeExecutorLzReceiveOption()?.value ?? 0n - const extraOptionsGas = extraOptions - ? Options.fromOptions(extraOptions).decodeExecutorLzReceiveOption()?.gas ?? 0n - : 0n - const extraOptionsValue = extraOptions - ? Options.fromOptions(extraOptions).decodeExecutorLzReceiveOption()?.value ?? 0n - : 0n - const totalOptionsValue = enforcedOptionsValue + extraOptionsValue - let valueShortfall = 0n - // if minimumLzReceiveValue is greater than totalOptionsValue, we need to add the difference to the amount - if (minimumLzReceiveValue && BigInt(minimumLzReceiveValue) > totalOptionsValue) { - console.info( - `minimum lzReceive value needed is greater than the total options value, adding extraOptions to cover the difference: ${minimumLzReceiveValue} (minimum) - ${totalOptionsValue} (total) = ${valueShortfall} (shortfall)` - ) - valueShortfall = BigInt(minimumLzReceiveValue) - totalOptionsValue - } + const { gasShortfall, valueShortfall } = calculateGasAndValueShortfall( + enforcedOptions, + extraOptions, + minimumLzReceiveValue + ) if (valueShortfall > 0n) { // if there's a value shortfall, we add the difference as extraOptions - extraOptions = Options.newOptions().addExecutorLzReceiveOption(extraOptionsGas, valueShortfall).toHex() + extraOptions = Options.newOptions().addExecutorLzReceiveOption(gasShortfall, valueShortfall).toHex() } // EOF: evaluate whether options require additional value From a8b2585eb26a0762a52a295f6473e3f106802c65 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Sun, 31 Aug 2025 02:56:34 +0800 Subject: [PATCH 16/23] support token2022 --- examples/oft-solana/tasks/common/sendOFT.ts | 12 ++-- examples/oft-solana/tasks/solana/utils.ts | 65 +++++++++++---------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index c6b3221f2..c25307c1e 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -1,10 +1,11 @@ +import { publicKey } from '@metaplex-foundation/umi' import { task, types } from 'hardhat/config' import { HardhatRuntimeEnvironment } from 'hardhat/types' import { ChainType, endpointIdToChainType, endpointIdToNetwork } from '@layerzerolabs/lz-definitions' import { EvmArgs, sendEvm } from '../evm/sendEvm' -import { getSolanaDeployment } from '../solana' +import { deriveConnection, getSolanaDeployment } from '../solana' import { SolanaArgs, sendSolana } from '../solana/sendSolana' import { getMinimumValueForSendToSolana } from '../solana/utils' @@ -82,12 +83,15 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') // If sending to Solana, compute minimum value needed for ATA creation if (dstChainType === ChainType.SOLANA) { const solanaDeployment = getSolanaDeployment(args.dstEid) + const { connection, umi } = await deriveConnection(args.dstEid) // determines the absolute minimum value needed for an OFT send to Solana (based on ATA creation status) minimumLzReceiveValue = await getMinimumValueForSendToSolana({ - eid: args.dstEid, - recipient: args.to, - mint: args.dstOftAddress || solanaDeployment.mint, + recipient: publicKey(args.to), + mint: publicKey(args.dstOftAddress || solanaDeployment.mint), + umi, + connection, }) + console.log('minimumLzReceiveValue', minimumLzReceiveValue) args.minimumLzReceiveValue = minimumLzReceiveValue } diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index cd8fb0475..063cb5e4f 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -1,6 +1,13 @@ -import { findAssociatedTokenPda, safeFetchMint, safeFetchToken } from '@metaplex-foundation/mpl-toolbox' +import { findAssociatedTokenPda, safeFetchToken } from '@metaplex-foundation/mpl-toolbox' import { PublicKey, Umi, publicKey } from '@metaplex-foundation/umi' -import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { toWeb3JsPublicKey } from '@metaplex-foundation/umi-web3js-adapters' +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + Mint as Web3Mint, + getAccountLenForMint, + unpackMint, +} from '@solana/spl-token' import { Connection } from '@solana/web3.js' import { HardhatRuntimeEnvironment } from 'hardhat/types' @@ -13,10 +20,7 @@ import { TASK_LZ_OAPP_CONFIG_GET, } from '@layerzerolabs/ua-devtools-evm-hardhat' -import { deriveConnection } from './index' - export const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2_039_280 // This figure represents lamports (https://solana.com/docs/references/terminology#lamport) on Solana. Read below for more details. -export const TOKEN_2022_ACCOUNT_RENT_VALUE = 2_500_000 // NOTE: The actual value needed depends on which specific extensions you have enabled for your Token2022 token. You would need to determine this value based on your own Token2022 token. export const findSolanaEndpointIdInGraph = async ( hre: HardhatRuntimeEnvironment, @@ -103,22 +107,19 @@ export enum SolanaTokenType { * Returns the derived ATA and a boolean indicating existence. */ export async function checkAssociatedTokenAccountExists(args: { - umi?: Umi - eid: EndpointId - mint: PublicKey | string - owner: PublicKey | string -}): Promise<{ ata: string; ataExists: boolean; tokenType: SolanaTokenType | null }> { - const { umi: providedUmi, eid, mint, owner } = args - const umi = providedUmi ?? (await deriveConnection(eid, true)).umi - - const mintPk = typeof mint === 'string' ? publicKey(mint) : mint - const ownerPk = typeof owner === 'string' ? publicKey(owner) : owner - const mintAccount = await safeFetchMint(umi, mintPk) - if (!mintAccount) throw new Error(`Mint not found: ${mintPk}`) + connection: Connection + umi: Umi + mint: PublicKey + owner: PublicKey +}): Promise<{ ata: string; ataExists: boolean; tokenType: SolanaTokenType | null; mintAccount: Web3Mint }> { + const { umi, mint, owner, connection } = args + + const mintAccountInfo = await connection.getAccountInfo(toWeb3JsPublicKey(mint)) + if (!mintAccountInfo) throw new Error(`Mint not found: ${mint}`) let tokenType: SolanaTokenType let tokenProgramId: string - switch (mintAccount.header.owner) { + switch (mintAccountInfo.owner.toBase58()) { case TOKEN_PROGRAM_ID.toBase58(): tokenType = SolanaTokenType.SPL tokenProgramId = TOKEN_PROGRAM_ID.toBase58() @@ -128,20 +129,21 @@ export async function checkAssociatedTokenAccountExists(args: { tokenProgramId = TOKEN_2022_PROGRAM_ID.toBase58() break default: - throw new Error(`Unknown token program: ${mintAccount.header.owner}`) + throw new Error(`Unknown token program: ${mintAccountInfo.owner}`) } // Derive ATA with the matching token program id. const ataPda = findAssociatedTokenPda(umi, { - mint: mintPk, - owner: ownerPk, + mint, + owner, tokenProgramId: publicKey(tokenProgramId), }) const ataPk = ataPda[0] const account = await safeFetchToken(umi, ataPk) + const mintAccount = unpackMint(toWeb3JsPublicKey(mint), mintAccountInfo, TOKEN_2022_PROGRAM_ID) - return { ata: ataPk, ataExists: !!account, tokenType } + return { ata: ataPk, ataExists: !!account, tokenType, mintAccount } } /** @@ -150,18 +152,18 @@ export async function checkAssociatedTokenAccountExists(args: { * Returns SPL_TOKEN_ACCOUNT_RENT_VALUE if the recipient ATA is missing and the mint is SPL. */ export async function getMinimumValueForSendToSolana(args: { - eid: EndpointId - recipient: string - mint: string | PublicKey - umi?: Umi + recipient: PublicKey + mint: PublicKey + umi: Umi + connection: Connection }): Promise { - const { eid, recipient, mint, umi } = args + const { recipient, mint, umi, connection } = args // Note that there may still exist a race condition and stale RPC data issue // Race Condition 1: First send to address X on Solana is still in flight, and the second send to address X on Solana is initiated. The second send would evaluate the ATA as not yet created. // Stale RPC data issue: The ATA might have been created at t=0, but the RPC will only pick it up at t=X but a send was initiated at t < x. - const { ata, ataExists, tokenType } = await checkAssociatedTokenAccountExists({ - eid, + const { ata, ataExists, tokenType, mintAccount } = await checkAssociatedTokenAccountExists({ owner: recipient, + connection, mint, umi, }) @@ -172,8 +174,9 @@ export async function getMinimumValueForSendToSolana(args: { console.info('ATA does not exist for the recipient and mint is SPL') return SPL_TOKEN_ACCOUNT_RENT_VALUE } else if (tokenType === SolanaTokenType.TOKEN2022) { - console.warn('Ensure that TOKEN_2022_ACCOUNT_RENT_VALUE matches your token account size') - return TOKEN_2022_ACCOUNT_RENT_VALUE + const tokenAccountSize = getAccountLenForMint(mintAccount) + const rentExemptLamports = await connection.getMinimumBalanceForRentExemption(tokenAccountSize) // it might be possible for you to avoid this RPC call. refer to https://x.com/0xNazreen/status/1945107841754243570 + return rentExemptLamports } } else { // if the ATA exists, we return 0 From c121afde4da634a087e1fe78b9e3e5c41e3b58d6 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Sun, 31 Aug 2025 02:58:40 +0800 Subject: [PATCH 17/23] fix --- examples/oft-solana/tasks/common/sendOFT.ts | 1 - examples/oft-solana/tasks/solana/utils.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/oft-solana/tasks/common/sendOFT.ts b/examples/oft-solana/tasks/common/sendOFT.ts index c25307c1e..59dc0da45 100644 --- a/examples/oft-solana/tasks/common/sendOFT.ts +++ b/examples/oft-solana/tasks/common/sendOFT.ts @@ -91,7 +91,6 @@ task('lz:oft:send', 'Sends OFT tokens cross‐chain from any supported chain') umi, connection, }) - console.log('minimumLzReceiveValue', minimumLzReceiveValue) args.minimumLzReceiveValue = minimumLzReceiveValue } diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index 063cb5e4f..8cae5d859 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -141,7 +141,7 @@ export async function checkAssociatedTokenAccountExists(args: { const ataPk = ataPda[0] const account = await safeFetchToken(umi, ataPk) - const mintAccount = unpackMint(toWeb3JsPublicKey(mint), mintAccountInfo, TOKEN_2022_PROGRAM_ID) + const mintAccount = unpackMint(toWeb3JsPublicKey(mint), mintAccountInfo, mintAccountInfo.owner) return { ata: ataPk, ataExists: !!account, tokenType, mintAccount } } From 978c1c0544c980111de5c58d4f19742ba7ced12e Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Sun, 31 Aug 2025 02:59:35 +0800 Subject: [PATCH 18/23] rm --- examples/oft-solana/tasks/solana/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index 8cae5d859..0c0260793 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -148,7 +148,7 @@ export async function checkAssociatedTokenAccountExists(args: { /** * Compute the minimum required per-transaction msg.value to attach when sending to Solana. - * Returns 0 if the recipient ATA already exists or if the mint is Token2022. + * Returns 0 if the recipient ATA already exists * Returns SPL_TOKEN_ACCOUNT_RENT_VALUE if the recipient ATA is missing and the mint is SPL. */ export async function getMinimumValueForSendToSolana(args: { @@ -175,7 +175,7 @@ export async function getMinimumValueForSendToSolana(args: { return SPL_TOKEN_ACCOUNT_RENT_VALUE } else if (tokenType === SolanaTokenType.TOKEN2022) { const tokenAccountSize = getAccountLenForMint(mintAccount) - const rentExemptLamports = await connection.getMinimumBalanceForRentExemption(tokenAccountSize) // it might be possible for you to avoid this RPC call. refer to https://x.com/0xNazreen/status/1945107841754243570 + const rentExemptLamports = await connection.getMinimumBalanceForRentExemption(tokenAccountSize) return rentExemptLamports } } else { From 2a5f868b27bc3face96c96a988b660d27fb38a76 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Sun, 31 Aug 2025 02:59:57 +0800 Subject: [PATCH 19/23] clean up --- examples/oft-solana/tasks/solana/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index 0c0260793..462b5a409 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -111,7 +111,7 @@ export async function checkAssociatedTokenAccountExists(args: { umi: Umi mint: PublicKey owner: PublicKey -}): Promise<{ ata: string; ataExists: boolean; tokenType: SolanaTokenType | null; mintAccount: Web3Mint }> { +}): Promise<{ ata: string; ataExists: boolean; tokenType: SolanaTokenType; mintAccount: Web3Mint }> { const { umi, mint, owner, connection } = args const mintAccountInfo = await connection.getAccountInfo(toWeb3JsPublicKey(mint)) From 4818b15f2ab9b2be14f6e71d50d237ca9a5fa32f Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:17:00 +0800 Subject: [PATCH 20/23] refactor: create getMintAccountInfo --- examples/oft-solana/tasks/solana/utils.ts | 58 +++++++++++++++-------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index 462b5a409..b3c556ab3 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -8,9 +8,10 @@ import { getAccountLenForMint, unpackMint, } from '@solana/spl-token' -import { Connection } from '@solana/web3.js' +import { AccountInfo, Connection } from '@solana/web3.js' import { HardhatRuntimeEnvironment } from 'hardhat/types' +import { createLogger } from '@layerzerolabs/io-devtools' import { ChainType, EndpointId, endpointIdToChainType } from '@layerzerolabs/lz-definitions' import { OAppOmniGraph } from '@layerzerolabs/ua-devtools' import { @@ -22,6 +23,8 @@ import { export const SPL_TOKEN_ACCOUNT_RENT_VALUE = 2_039_280 // This figure represents lamports (https://solana.com/docs/references/terminology#lamport) on Solana. Read below for more details. +const logger = createLogger() + export const findSolanaEndpointIdInGraph = async ( hre: HardhatRuntimeEnvironment, oappConfig: string @@ -102,6 +105,20 @@ export enum SolanaTokenType { TOKEN2022 = 'token2022', } +/** + * Get the mint account info for a given mint, for use by the functions `checkAssociatedTokenAccountExists` and ... + */ +export async function getMintAccountInfo(args: { + connection: Connection + mint: PublicKey +}): Promise<{ mintAccountInfo: AccountInfo; mintAccount: Web3Mint }> { + const { connection, mint } = args + const mintAccountInfo = await connection.getAccountInfo(toWeb3JsPublicKey(mint)) + if (!mintAccountInfo) throw new Error(`Mint not found: ${mint}`) + const mintAccount = unpackMint(toWeb3JsPublicKey(mint), mintAccountInfo, mintAccountInfo.owner) // this is done for SPL accounts too, but that's fine since this isn't an RPC call + return { mintAccountInfo, mintAccount } +} + /** * Check if an Associated Token Account (ATA) exists for a given mint and owner. * Returns the derived ATA and a boolean indicating existence. @@ -110,12 +127,10 @@ export async function checkAssociatedTokenAccountExists(args: { connection: Connection umi: Umi mint: PublicKey + mintAccountInfo: AccountInfo owner: PublicKey -}): Promise<{ ata: string; ataExists: boolean; tokenType: SolanaTokenType; mintAccount: Web3Mint }> { - const { umi, mint, owner, connection } = args - - const mintAccountInfo = await connection.getAccountInfo(toWeb3JsPublicKey(mint)) - if (!mintAccountInfo) throw new Error(`Mint not found: ${mint}`) +}): Promise<{ ata: PublicKey; ataExists: boolean; tokenType: SolanaTokenType }> { + const { umi, mintAccountInfo, owner, mint } = args let tokenType: SolanaTokenType let tokenProgramId: string @@ -140,10 +155,9 @@ export async function checkAssociatedTokenAccountExists(args: { }) const ataPk = ataPda[0] - const account = await safeFetchToken(umi, ataPk) - const mintAccount = unpackMint(toWeb3JsPublicKey(mint), mintAccountInfo, mintAccountInfo.owner) + const ataAccount = await safeFetchToken(umi, ataPk) - return { ata: ataPk, ataExists: !!account, tokenType, mintAccount } + return { ata: ataPk, ataExists: !!ataAccount, tokenType } } /** @@ -158,29 +172,35 @@ export async function getMinimumValueForSendToSolana(args: { connection: Connection }): Promise { const { recipient, mint, umi, connection } = args + const { mintAccountInfo, mintAccount } = await getMintAccountInfo({ connection, mint }) // Note that there may still exist a race condition and stale RPC data issue // Race Condition 1: First send to address X on Solana is still in flight, and the second send to address X on Solana is initiated. The second send would evaluate the ATA as not yet created. // Stale RPC data issue: The ATA might have been created at t=0, but the RPC will only pick it up at t=X but a send was initiated at t < x. - const { ata, ataExists, tokenType, mintAccount } = await checkAssociatedTokenAccountExists({ + const { ata, ataExists, tokenType } = await checkAssociatedTokenAccountExists({ owner: recipient, connection, mint, + mintAccountInfo, umi, }) - console.info(`ATA: ${ata}, ATA exists: ${ataExists}, tokenType: ${tokenType}`) - if (!ataExists) { + logger.info(`ATA: ${ata}, ATA exists: ${ataExists}, tokenType: ${tokenType}`) + + if (ataExists) { + return 0 + } + + switch (tokenType) { // if the ATA does not exist, we return the minimum value needed for the ATA creation - if (tokenType === SolanaTokenType.SPL) { - console.info('ATA does not exist for the recipient and mint is SPL') + case SolanaTokenType.SPL: + logger.info('ATA does not exist for the recipient and mint is SPL') return SPL_TOKEN_ACCOUNT_RENT_VALUE - } else if (tokenType === SolanaTokenType.TOKEN2022) { + case SolanaTokenType.TOKEN2022: { + logger.info('ATA does not exist for the recipient and mint is TOKEN2022') const tokenAccountSize = getAccountLenForMint(mintAccount) const rentExemptLamports = await connection.getMinimumBalanceForRentExemption(tokenAccountSize) return rentExemptLamports } - } else { - // if the ATA exists, we return 0 - return 0 + default: + throw new Error(`Unknown token type: ${tokenType}`) } - return 0 } From 5fe098ca25d23d445b29093ba46d5f543a7f33c0 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:21:56 +0800 Subject: [PATCH 21/23] refactor: getMinimumRentForToken2022TokenAccount --- examples/oft-solana/tasks/solana/utils.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index b3c556ab3..28bf808af 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -134,6 +134,8 @@ export async function checkAssociatedTokenAccountExists(args: { let tokenType: SolanaTokenType let tokenProgramId: string + + // check tokenType and tokenProgramId switch (mintAccountInfo.owner.toBase58()) { case TOKEN_PROGRAM_ID.toBase58(): tokenType = SolanaTokenType.SPL @@ -196,11 +198,20 @@ export async function getMinimumValueForSendToSolana(args: { return SPL_TOKEN_ACCOUNT_RENT_VALUE case SolanaTokenType.TOKEN2022: { logger.info('ATA does not exist for the recipient and mint is TOKEN2022') - const tokenAccountSize = getAccountLenForMint(mintAccount) - const rentExemptLamports = await connection.getMinimumBalanceForRentExemption(tokenAccountSize) + const rentExemptLamports = await getMinimumRentForToken2022TokenAccount({ connection, mintAccount }) return rentExemptLamports } default: throw new Error(`Unknown token type: ${tokenType}`) } } + +async function getMinimumRentForToken2022TokenAccount(args: { + connection: Connection + mintAccount: Web3Mint +}): Promise { + const { connection, mintAccount } = args + const tokenAccountSize = getAccountLenForMint(mintAccount) + const rentExemptLamports = await connection.getMinimumBalanceForRentExemption(tokenAccountSize) + return rentExemptLamports +} From 07945f57a6bdcad5e541adc12ddb11bfe4b7beaa Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:29:42 +0800 Subject: [PATCH 22/23] clean up --- examples/oft-solana/tasks/solana/utils.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index 28bf808af..bfe90b2a3 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -106,9 +106,9 @@ export enum SolanaTokenType { } /** - * Get the mint account info for a given mint, for use by the functions `checkAssociatedTokenAccountExists` and ... + * Get the mint account info for a given mint, for use by the functions `checkAssociatedTokenAccountExists` and `getMinimumRentForToken2022TokenAccount` */ -export async function getMintAccountInfo(args: { +async function getMintAccountInfo(args: { connection: Connection mint: PublicKey }): Promise<{ mintAccountInfo: AccountInfo; mintAccount: Web3Mint }> { @@ -198,8 +198,7 @@ export async function getMinimumValueForSendToSolana(args: { return SPL_TOKEN_ACCOUNT_RENT_VALUE case SolanaTokenType.TOKEN2022: { logger.info('ATA does not exist for the recipient and mint is TOKEN2022') - const rentExemptLamports = await getMinimumRentForToken2022TokenAccount({ connection, mintAccount }) - return rentExemptLamports + return await getMinimumRentForToken2022TokenAccount({ connection, mintAccount }) } default: throw new Error(`Unknown token type: ${tokenType}`) From 5be2ff0dfeb3a5517ed3e51b72087394e8e65cc8 Mon Sep 17 00:00:00 2001 From: nazreen <10964594+nazreen@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:38:10 +0800 Subject: [PATCH 23/23] drop unused --- examples/oft-solana/tasks/solana/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/oft-solana/tasks/solana/utils.ts b/examples/oft-solana/tasks/solana/utils.ts index bfe90b2a3..bb239667b 100644 --- a/examples/oft-solana/tasks/solana/utils.ts +++ b/examples/oft-solana/tasks/solana/utils.ts @@ -124,7 +124,6 @@ async function getMintAccountInfo(args: { * Returns the derived ATA and a boolean indicating existence. */ export async function checkAssociatedTokenAccountExists(args: { - connection: Connection umi: Umi mint: PublicKey mintAccountInfo: AccountInfo