Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8e5a8a9
ci flag
nazreen Oct 16, 2025
c176cd1
max total supply better warning
nazreen Oct 16, 2025
87caae3
extract into utils
nazreen Oct 16, 2025
b1cfbca
apply to createOFTAdapter too
nazreen Oct 16, 2025
d2ec9f4
changeset
nazreen Oct 16, 2025
dbc1c13
remove max recommended local decimals
nazreen Oct 17, 2025
ca73466
Merge branch 'main' into oft-solana-create-oft-guardrails
nazreen Oct 18, 2025
8ad0453
PR feedback
nazreen Oct 22, 2025
849e953
rename function
nazreen Oct 22, 2025
1ab88cd
split functions
nazreen Oct 22, 2025
b431511
remove wrapper
nazreen Oct 22, 2025
253c44f
Merge branch 'main' into oft-solana-create-oft-guardrails
nazreen Oct 22, 2025
2ad6f57
Update six-dots-occur.md
nazreen Oct 22, 2025
1e11730
test
nazreen Oct 27, 2025
924f330
fix
nazreen Oct 27, 2025
da043ee
test lzapp
nazreen Oct 27, 2025
8ff2a9f
amend
nazreen Oct 27, 2025
0262db2
gen lockfiles
nazreen Oct 27, 2025
7a25851
update hardhat config
nazreen Oct 27, 2025
4d7b92c
internalize into package
nazreen Oct 27, 2025
2568a11
changeset
nazreen Oct 27, 2025
ee2b551
rm unused
nazreen Oct 27, 2025
3c03471
rename
nazreen Oct 31, 2025
c8ccfb4
rename param
nazreen Oct 31, 2025
85cbd4d
rename function
nazreen Oct 31, 2025
be1c8c6
rename
nazreen Oct 31, 2025
952a92e
update message to print whole raw value
nazreen Oct 31, 2025
ed5c0bd
rename test file
nazreen Oct 31, 2025
b4ae439
amend
nazreen Oct 31, 2025
1da4389
amend formatTokenAmount
nazreen Nov 2, 2025
d41d2a8
Merge branch 'main' into oft-solana-create-oft-guardrails
nazreen Nov 4, 2025
583cc72
Merge branch 'main' into oft-solana-create-oft-guardrails
nazreen Nov 5, 2025
991929d
destructure
nazreen Nov 6, 2025
1df80a2
no import barelling
nazreen Nov 6, 2025
00b8de4
amend message
nazreen Nov 6, 2025
14e617d
correct assertion placement
nazreen Nov 6, 2025
1ce2c44
Merge branch 'main' into oft-solana-create-oft-guardrails
nazreen Nov 6, 2025
8f92c72
Merge branch 'main' into oft-solana-create-oft-guardrails
nazreen Nov 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/six-dots-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@layerzerolabs/lzapp-migration-example": patch
"@layerzerolabs/oft-solana-example": patch
---

oft-solana: require user to confirm max total token supply given a local decimals value
19 changes: 17 additions & 2 deletions examples/lzapp-migration/tasks/solana/createOFT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ import { types as devtoolsTypes } from '@layerzerolabs/devtools-evm-hardhat'
import { assertAccountInitialized } from '@layerzerolabs/devtools-solana'
import { promptToContinue } from '@layerzerolabs/io-devtools'
import { EndpointId } from '@layerzerolabs/lz-definitions'
import { OFT_DECIMALS as DEFAULT_SHARED_DECIMALS, oft202 } from '@layerzerolabs/oft-v2-solana-sdk'
import { OFT_DECIMALS as DEFAULT_SHARED_DECIMALS, oft202 } from '@layerzerolabs/oft-v2-solana-sdk' // Note: 'oft202' should be used instead of 'oft'

import { checkMultisigSigners, createMintAuthorityMultisig } from './multisig'
import { formatAmount, localDecimalsToMaxSupply } from './utils'

import {
TransactionType,
Expand Down Expand Up @@ -117,6 +118,11 @@ interface CreateOFTTaskArgs {
* The freeze authority address (only supported in onlyOftStore mode).
*/
freezeAuthority?: string

/**
* Whether to continue without confirmation.
*/
ci: boolean
}

// Define a Hardhat task for creating OFT on Solana
Expand Down Expand Up @@ -158,6 +164,7 @@ task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Store acco
true
)
.addParam('computeUnitPriceScaleFactor', 'The compute unit price scale factor', 4, devtoolsTypes.float, true)
.addFlag('ci', 'Continue without confirmation')
.setAction(
async ({
amount,
Expand All @@ -176,6 +183,7 @@ task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Store acco
uri,
freezeAuthority: freezeAuthorityStr,
computeUnitPriceScaleFactor,
ci,
}: CreateOFTTaskArgs) => {
const isMABA = !!mintStr // the difference between MABA and OFT Adapter is that MABA uses mint/burn mechanism whereas OFT Adapter uses lock/unlock mechanism
if (tokenProgramStr !== TOKEN_PROGRAM_ID.toBase58() && !isMABA) {
Expand Down Expand Up @@ -212,7 +220,7 @@ task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Store acco
)
}

if (onlyOftStore) {
if (onlyOftStore && !ci) {
const continueWithOnlyOftStore = await promptToContinue(
`You have chosen \`--only-oft-store true\`. This means that only the OFT Store will be able to mint new tokens${freezeAuthorityStr ? '' : ' and that the Freeze Authority will be immediately renounced'}. Continue?`
)
Expand All @@ -222,6 +230,13 @@ task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Store acco
}
// EOF: Validate combination of parameters

const maxSupply = formatAmount(localDecimalsToMaxSupply(decimals))
const maxSupplyStatement = `You have chosen ${decimals} local decimals. The maximum supply of your Solana OFT token will be ${maxSupply}.\n`
const confirmMaxSupply = await promptToContinue(maxSupplyStatement)
if (!confirmMaxSupply) {
return
}

let mintAuthorityPublicKey: PublicKey = toWeb3JsPublicKey(oftStorePda) // we default to the OFT Store as the Mint Authority when there are no additional minters

if (additionalMintersAsStrings) {
Expand Down
78 changes: 78 additions & 0 deletions examples/lzapp-migration/tasks/solana/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,81 @@ export function silenceSolana429(connection: Connection): void {
return origWrite(chunk, ...args)
}) as typeof process.stderr.write
}

// Max whole-token supply on Solana (u64)
const U64_MAX = (1n << 64n) - 1n
const UNITS = [
{ base: 1_000_000_000_000n, suffix: 'T' },
{ base: 1_000_000_000n, suffix: 'B' },
{ base: 1_000_000n, suffix: 'M' },
{ base: 1_000n, suffix: 'K' },
]

/**
* Computes the maximum whole-token supply representable by a Solana SPL mint
* given its local decimal precision, formatted for readability.
*
* Behavior:
* - Uses compact units when large enough: K (thousand), M (million), B (billion), T (trillion).
* - Values < 1000 are returned as a plain whole number string without a suffix.
* - Rounds "half up" to the provided display precision.
* - Near unit boundaries, rounding can promote the value to the next unit
* (e.g., 999.6B -> 1.0T) which matches typical human-readable expectations.
*
* Parameters:
* - localDecimals: Non-negative integer count of decimals on the SPL mint.
* - maxDisplayDecimals: Fractional digits to include in the compact representation (0–6). Default is 1.
*
* Returns:
* - A human-readable string such as "18.4B", "1.0T", or "842".
*
* Throws:
* - If localDecimals is negative or not an integer.
* - If maxDisplayDecimals is not an integer in the inclusive range [0, 6].
*
* Examples:
* - maxSupplyWholeTokens(9) -> "18.4B" // typical 9-decimal mint
* - maxSupplyWholeTokens(12) -> "18.4M"
* - maxSupplyWholeTokens(18) -> "18" // < 1K, returned as a plain number
*/
/** Returns the maximum whole-token supply (as bigint) for a given local decimal precision. */
export function localDecimalsToMaxSupply(localDecimals: number): bigint {
if (!Number.isInteger(localDecimals) || localDecimals < 0) {
throw new Error('localDecimals must be a non-negative integer')
}
const scalingFactor = 10n ** BigInt(localDecimals)
return U64_MAX / scalingFactor
}

/** Formats a whole-token amount into a compact human-readable form using K/M/B/T. */
export function formatAmount(whole: bigint, maxDisplayDecimals = 1): string {
if (!Number.isInteger(maxDisplayDecimals) || maxDisplayDecimals < 0 || maxDisplayDecimals > 6) {
throw new Error('precision must be an integer between 0 and 6')
}

for (let i = 0; i < UNITS.length; i++) {
const { base, suffix } = UNITS[i]
if (whole >= base) {
const pow = 10n ** BigInt(maxDisplayDecimals)
let scaled = (whole * pow + base / 2n) / base
let intPart = scaled / pow

if (intPart >= 1000n && i === 1) {
const higher = UNITS[0]
scaled = (whole * pow + higher.base / 2n) / higher.base
intPart = scaled / pow
const frac = scaled % pow
const fracStr =
maxDisplayDecimals === 0 ? '' : frac.toString().padStart(maxDisplayDecimals, '0').replace(/0+$/, '')
return fracStr ? `${intPart}.${fracStr}${higher.suffix}` : `${intPart}${higher.suffix}`
}

const frac = scaled % pow
const fracStr =
maxDisplayDecimals === 0 ? '' : frac.toString().padStart(maxDisplayDecimals, '0').replace(/0+$/, '')
return fracStr ? `${intPart}.${fracStr}${suffix}` : `${intPart}${suffix}`
}
}

return whole.toString()
}
17 changes: 16 additions & 1 deletion examples/oft-solana/tasks/solana/createOFT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { EndpointId } from '@layerzerolabs/lz-definitions'
import { OFT_DECIMALS as DEFAULT_SHARED_DECIMALS, oft } from '@layerzerolabs/oft-v2-solana-sdk'

import { checkMultisigSigners, createMintAuthorityMultisig } from './multisig'
import { formatAmount, localDecimalsToMaxSupply } from './utils'

import {
TransactionType,
Expand Down Expand Up @@ -117,6 +118,11 @@ interface CreateOFTTaskArgs {
* The freeze authority address (only supported in onlyOftStore mode).
*/
freezeAuthority?: string

/**
* Whether to continue without confirmation.
*/
ci: boolean
}

// Define a Hardhat task for creating OFT on Solana
Expand Down Expand Up @@ -158,6 +164,7 @@ task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Store acco
true
)
.addParam('computeUnitPriceScaleFactor', 'The compute unit price scale factor', 4, devtoolsTypes.float, true)
.addFlag('ci', 'Continue without confirmation')
.setAction(
async ({
amount,
Expand All @@ -176,6 +183,7 @@ task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Store acco
uri,
freezeAuthority: freezeAuthorityStr,
computeUnitPriceScaleFactor,
ci,
}: CreateOFTTaskArgs) => {
const isMABA = !!mintStr // the difference between MABA and OFT Adapter is that MABA uses mint/burn mechanism whereas OFT Adapter uses lock/unlock mechanism
if (tokenProgramStr !== TOKEN_PROGRAM_ID.toBase58() && !isMABA) {
Expand Down Expand Up @@ -212,7 +220,7 @@ task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Store acco
)
}

if (onlyOftStore) {
if (onlyOftStore && !ci) {
const continueWithOnlyOftStore = await promptToContinue(
`You have chosen \`--only-oft-store true\`. This means that only the OFT Store will be able to mint new tokens${freezeAuthorityStr ? '' : ' and that the Freeze Authority will be immediately renounced'}. Continue?`
)
Expand All @@ -223,6 +231,13 @@ task('lz:oft:solana:create', 'Mints new SPL Token and creates new OFT Store acco

// EOF: Validate combination of parameters

const maxSupply = formatAmount(localDecimalsToMaxSupply(decimals))
const maxSupplyStatement = `You have chosen ${decimals} local decimals. The maximum supply of your Solana OFT token will be ${maxSupply}.\n`
const confirmMaxSupply = await promptToContinue(maxSupplyStatement)
if (!confirmMaxSupply) {
return
}

let mintAuthorityPublicKey: PublicKey = toWeb3JsPublicKey(oftStorePda) // we default to the OFT Store as the Mint Authority when there are no additional minters

if (additionalMintersAsStrings) {
Expand Down
11 changes: 11 additions & 0 deletions examples/oft-solana/tasks/solana/createOFTAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import bs58 from 'bs58'
import { task } from 'hardhat/config'

import { types as devtoolsTypes } from '@layerzerolabs/devtools-evm-hardhat'
import { promptToContinue } from '@layerzerolabs/io-devtools'
import { EndpointId } from '@layerzerolabs/lz-definitions'
import { OFT_DECIMALS, oft } from '@layerzerolabs/oft-v2-solana-sdk'

import { formatAmount, localDecimalsToMaxSupply } from './utils'

import {
TransactionType,
addComputeUnitInstructions,
Expand Down Expand Up @@ -63,6 +66,14 @@ task('lz:oft-adapter:solana:create', 'Creates new OFT Adapter (OFT Store PDA)')
const mint = publicKey(mintStr)

const mintPDA = await getMint(connection, new PublicKey(mintStr), undefined, new PublicKey(tokenProgramStr))
const mintDecimals = mintPDA.decimals

const maxSupply = formatAmount(localDecimalsToMaxSupply(mintDecimals))
const maxSupplyStatement = `You provided Token Mint ${mintDecimals} local decimals. The maximum supply of your Solana OFT token will be ${maxSupply}.\n`
const confirmMaxSupply = await promptToContinue(maxSupplyStatement)
if (!confirmMaxSupply) {
return
}

const mintAuthority = mintPDA.mintAuthority

Expand Down
77 changes: 77 additions & 0 deletions examples/oft-solana/tasks/solana/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,80 @@ export function silenceSolana429(connection: Connection): void {
return origWrite(chunk, ...args)
}) as typeof process.stderr.write
}
// Max whole-token supply on Solana (u64) formatted as "XB" or "Y.YT"
const U64_MAX = (1n << 64n) - 1n
const UNITS = [
{ base: 1000000000000n, suffix: 'T' },
{ base: 1000000000n, suffix: 'B' },
{ base: 1000000n, suffix: 'M' },
{ base: 1000n, suffix: 'K' },
]
/**
* Compute the maximum whole-token supply for a Solana SPL mint given its
* local decimal precision.
*
* - Uses the u64 max value ((1 << 64) - 1) divided by the scaling factor
* 10^localDecimals to derive the whole-token cap.
* - Returns the whole-token cap as a bigint.
*
* @param localDecimals Non-negative integer count of decimals on the SPL mint.
* @returns bigint Whole-token maximum supply.
* @throws Error if localDecimals is not a non-negative integer.
*/
export function localDecimalsToMaxSupply(localDecimals: number): bigint {
if (!Number.isInteger(localDecimals) || localDecimals < 0) {
throw new Error('localDecimals must be a non-negative integer')
}
const scalingFactor = 10n ** BigInt(localDecimals)
return U64_MAX / scalingFactor
}

/**
* Format a whole-token amount into a compact human-readable string using
* unit suffixes: K (thousand), M (million), B (billion), T (trillion).
*
* Behavior:
* - Values < 1000 are returned as a plain whole number string (no suffix).
* - Rounds half up to the requested precision.
* - At boundaries, rounding may promote to the next unit (e.g., 999.6B → 1.0T).
*
* @param whole The whole-token quantity to format.
* @param maxDisplayDecimals Fractional digits to include (0–6). Default 1.
* @returns Human-readable string such as "18.4B", "1.0T", or "842".
* @throws Error if maxDisplayDecimals is not an integer in [0, 6].
*/
export function formatAmount(whole: bigint, maxDisplayDecimals = 1): string {
if (!Number.isInteger(maxDisplayDecimals) || maxDisplayDecimals < 0 || maxDisplayDecimals > 6) {
throw new Error('precision must be an integer between 0 and 6')
}

for (let i = 0; i < UNITS.length; i++) {
const { base, suffix } = UNITS[i]
if (whole >= base) {
const pow = 10n ** BigInt(maxDisplayDecimals)
// round half up at the requested precision
let scaled = (whole * pow + base / 2n) / base
let intPart = scaled / pow

// if rounding pushes us to 1000 of this unit, bump to the next larger (e.g., 999.6B -> 1.0T)
if (intPart >= 1000n && i === 1) {
// B -> T
const higher = UNITS[0]
scaled = (whole * pow + higher.base / 2n) / higher.base
intPart = scaled / pow
const frac = scaled % pow
const fracStr =
maxDisplayDecimals === 0 ? '' : frac.toString().padStart(maxDisplayDecimals, '0').replace(/0+$/, '')
return fracStr ? `${intPart}.${fracStr}${higher.suffix}` : `${intPart}${higher.suffix}`
}

const frac = scaled % pow
const fracStr =
maxDisplayDecimals === 0 ? '' : frac.toString().padStart(maxDisplayDecimals, '0').replace(/0+$/, '')
return fracStr ? `${intPart}.${fracStr}${suffix}` : `${intPart}${suffix}`
}
}

// < 1K -> show plain whole number
return whole.toString()
}