From 1d2a59ff291a23792b9bb1deda7824eabebbcd2b Mon Sep 17 00:00:00 2001 From: guibibeau Date: Tue, 13 Jan 2026 17:44:53 +0700 Subject: [PATCH] feat: add Token 2022 program support to SPL token helper - Add @solana-program/token-2022 dependency to pnpm catalog - Create detectTokenProgram utility for auto-detection - Update SplTokenHelper to support Token 2022 instructions - Support tokenProgram: 'auto' config option - Export TOKEN_2022_PROGRAM_ADDRESS constant - Update documentation and READMEs Closes #148 --- .changeset/token-2022-support.md | 11 +++ apps/docs/content/docs/client.mdx | 42 +++++++++ apps/docs/content/docs/react-hooks.mdx | 22 +++++ packages/client/README.md | 28 ++++++ packages/client/package.json | 1 + packages/client/src/features/spl.ts | 70 +++++++++++--- packages/client/src/features/tokenPrograms.ts | 93 +++++++++++++++++++ packages/client/src/index.ts | 9 ++ packages/react-hooks/README.md | 15 +++ pnpm-lock.yaml | 18 ++++ pnpm-workspace.yaml | 1 + 11 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 .changeset/token-2022-support.md create mode 100644 packages/client/src/features/tokenPrograms.ts diff --git a/.changeset/token-2022-support.md b/.changeset/token-2022-support.md new file mode 100644 index 0000000..ee5929f --- /dev/null +++ b/.changeset/token-2022-support.md @@ -0,0 +1,11 @@ +--- +"@solana/client": minor +"@solana/react-hooks": minor +--- + +Add Token 2022 (Token Extensions) program support to SPL token helper. + +- New `tokenProgram: 'auto'` option to auto-detect mint program ownership +- Explicit Token 2022 program address support via `tokenProgram` config +- Export `TOKEN_2022_PROGRAM_ADDRESS` and `detectTokenProgram` utility +- Backwards compatible - existing code continues to work unchanged diff --git a/apps/docs/content/docs/client.mdx b/apps/docs/content/docs/client.mdx index e31f125..a19d810 100644 --- a/apps/docs/content/docs/client.mdx +++ b/apps/docs/content/docs/client.mdx @@ -205,6 +205,48 @@ const signature = await usdc.sendTransfer({ }); ``` +### Token 2022 Support + +The SPL token helper supports Token 2022 (Token Extensions) mints. Use the `tokenProgram` option to specify the program: + +```ts +// Auto-detect program (recommended for existing mints) +const token2022 = client.splToken({ + mint: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", // PYUSD + tokenProgram: "auto", +}); + +// Fetch balance works the same way +const balance = await token2022.fetchBalance(wallet.session.account.address); + +// Transfer works the same way +const signature = await token2022.sendTransfer({ + amount: 10, + authority: wallet.session, + destinationOwner: recipientAddress, +}); +``` + +You can also explicitly specify the Token 2022 program address: + +```ts +import { TOKEN_2022_PROGRAM_ADDRESS } from "@solana/client"; + +const token = client.splToken({ + mint: mintAddress, + tokenProgram: TOKEN_2022_PROGRAM_ADDRESS, +}); +``` + +The `detectTokenProgram` utility is also available for manual program detection: + +```ts +import { detectTokenProgram } from "@solana/client"; + +const result = await detectTokenProgram(client.runtime, mintAddress); +console.log(result.programId); // 'token' or 'token-2022' +``` + ## Custom Transactions Build and send arbitrary transactions: diff --git a/apps/docs/content/docs/react-hooks.mdx b/apps/docs/content/docs/react-hooks.mdx index af3dd7b..b871d51 100644 --- a/apps/docs/content/docs/react-hooks.mdx +++ b/apps/docs/content/docs/react-hooks.mdx @@ -305,6 +305,28 @@ function TokenPanel({ mint, destinationOwner }: { mint: string; destinationOwner | `owner` | Override balance owner (defaults to connected wallet) | | `revalidateOnFocus` | Refresh when window regains focus | | `swr` | Additional SWR options | +| `config.tokenProgram` | Token program: `'auto'` for detection, or explicit address | + +**Token 2022 Support:** + +The `useSplToken` hook supports Token 2022 mints via the `tokenProgram` config option: + +```tsx +function Token2022Panel({ mint }: { mint: string }) { + const { balance, send } = useSplToken(mint, { + config: { tokenProgram: "auto" }, // Auto-detect Token or Token 2022 + }); + + return ( +
+

Balance: {balance?.uiAmount ?? "0"}

+ +
+ ); +} +``` ### useWrapSol diff --git a/packages/client/README.md b/packages/client/README.md index 1558645..7ea7200 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -112,6 +112,34 @@ const signature = await usdc.sendTransfer({ console.log(signature.toString()); ``` +### Token 2022 support + +Token 2022 mints are supported via the `tokenProgram` option: + +```ts +// Auto-detect Token or Token 2022 (recommended) +const token = client.splToken({ + mint: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", // PYUSD + tokenProgram: "auto", +}); + +// Or explicitly specify Token 2022 program +import { TOKEN_2022_PROGRAM_ADDRESS } from "@solana/client"; + +const token2022 = client.splToken({ + mint: mintAddress, + tokenProgram: TOKEN_2022_PROGRAM_ADDRESS, +}); + +// Balance and transfers work the same way +const balance = await token.fetchBalance(wallet.session.account.address); +const signature = await token.sendTransfer({ + amount: 10, + authority: wallet.session, + destinationOwner: recipientAddress, +}); +``` + ### Fetch address lookup tables ```ts diff --git a/packages/client/package.json b/packages/client/package.json index 25bd9ca..a3c7a9d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -71,6 +71,7 @@ "@solana-program/system": "catalog:solana", "@solana-program/compute-budget": "catalog:solana", "@solana-program/token": "catalog:solana", + "@solana-program/token-2022": "catalog:solana", "@solana-program/address-lookup-table": "catalog:solana", "@wallet-standard/app": "catalog:solana", "@wallet-standard/base": "catalog:solana", diff --git a/packages/client/src/features/spl.ts b/packages/client/src/features/spl.ts index 5669eae..d7bfc2f 100644 --- a/packages/client/src/features/spl.ts +++ b/packages/client/src/features/spl.ts @@ -23,18 +23,23 @@ import { type TransactionVersion, } from '@solana/kit'; import { - fetchMint, + fetchMint as fetchMintStandard, findAssociatedTokenPda, getCreateAssociatedTokenInstruction, - getTransferCheckedInstruction, + getTransferCheckedInstruction as getTransferCheckedStandard, TOKEN_PROGRAM_ADDRESS, } from '@solana-program/token'; - +import { + fetchMint as fetchMintToken2022, + getTransferCheckedInstruction as getTransferCheckedToken2022, + TOKEN_2022_PROGRAM_ADDRESS, +} from '@solana-program/token-2022'; import { createTokenAmount, type TokenAmountMath } from '../numeric/amounts'; import type { SolanaClientRuntime } from '../rpc/types'; import { createWalletTransactionSigner, isWalletSession, resolveSignerMode } from '../signers/walletTransactionSigner'; import type { WalletSession } from '../wallet/types'; import type { SolTransferSendOptions } from './sol'; +import { detectTokenProgram } from './tokenPrograms'; /** * Blockhash and last valid block height for transaction lifetime. @@ -75,8 +80,12 @@ export type SplTokenHelperConfig = Readonly<{ decimals?: number; /** The SPL token mint address. */ mint: Address | string; - /** Token Program address. Defaults to standard Token Program. */ - tokenProgram?: Address | string; + /** + * Token Program address. Defaults to standard Token Program. + * Set to 'auto' to automatically detect based on mint account owner. + * Note: Auto-detection requires the mint to already exist on-chain. + */ + tokenProgram?: Address | string | 'auto'; }>; /** @@ -233,9 +242,10 @@ function resolveSigner( export type SplTokenHelper = Readonly<{ /** * Derives the Associated Token Account (ATA) address for an owner. - * The ATA is a deterministic address based on the owner and mint. + * The ATA is a deterministic address based on the owner, mint, and token program. + * When using tokenProgram: 'auto', the commitment is used for program detection. */ - deriveAssociatedTokenAddress(owner: Address | string): Promise
; + deriveAssociatedTokenAddress(owner: Address | string, commitment?: Commitment): Promise
; /** * Fetches the token balance for an owner's Associated Token Account. * Returns balance info including whether the account exists. @@ -300,15 +310,43 @@ export type SplTokenHelper = Readonly<{ */ export function createSplTokenHelper(runtime: SolanaClientRuntime, config: SplTokenHelperConfig): SplTokenHelper { const mintAddress = ensureAddress(config.mint); - const tokenProgram = ensureAddress(config.tokenProgram, address(TOKEN_PROGRAM_ADDRESS)); let cachedDecimals: number | undefined = config.decimals; let cachedMath: TokenAmountMath | undefined; + let resolvedTokenProgram: Address | undefined = + config.tokenProgram && config.tokenProgram !== 'auto' ? ensureAddress(config.tokenProgram) : undefined; + let isToken2022: boolean | undefined = + config.tokenProgram === 'auto' + ? undefined + : config.tokenProgram + ? config.tokenProgram === TOKEN_2022_PROGRAM_ADDRESS || + (typeof config.tokenProgram === 'string' && config.tokenProgram === TOKEN_2022_PROGRAM_ADDRESS) + : false; + + async function resolveTokenProgram(commitment?: Commitment): Promise
{ + if (resolvedTokenProgram) { + return resolvedTokenProgram; + } + + if (config.tokenProgram === 'auto') { + const result = await detectTokenProgram(runtime, mintAddress, commitment); + resolvedTokenProgram = result.programAddress; + isToken2022 = result.programId === 'token-2022'; + } else { + resolvedTokenProgram = address(TOKEN_PROGRAM_ADDRESS); + isToken2022 = false; + } + + return resolvedTokenProgram; + } async function resolveDecimals(commitment?: Commitment): Promise { if (cachedDecimals !== undefined) { return cachedDecimals; } + + const tokenProgram = await resolveTokenProgram(commitment); + const fetchMint = tokenProgram === TOKEN_2022_PROGRAM_ADDRESS ? fetchMintToken2022 : fetchMintStandard; const account = await fetchMint(runtime.rpc, mintAddress, { commitment }); cachedDecimals = account.data.decimals; return cachedDecimals; @@ -323,7 +361,8 @@ export function createSplTokenHelper(runtime: SolanaClientRuntime, config: SplTo return cachedMath; } - async function deriveAssociatedTokenAddress(owner: Address | string): Promise
{ + async function deriveAssociatedTokenAddress(owner: Address | string, commitment?: Commitment): Promise
{ + const tokenProgram = await resolveTokenProgram(commitment); const [ata] = await findAssociatedTokenPda({ mint: mintAddress, owner: ensureAddress(owner), @@ -333,7 +372,7 @@ export function createSplTokenHelper(runtime: SolanaClientRuntime, config: SplTo } async function fetchBalance(owner: Address | string, commitment?: Commitment): Promise { - const ataAddress = await deriveAssociatedTokenAddress(owner); + const ataAddress = await deriveAssociatedTokenAddress(owner, commitment); const decimals = await resolveDecimals(commitment); try { const { value } = await runtime.rpc.getTokenAccountBalance(ataAddress, { commitment }).send(); @@ -360,15 +399,19 @@ export function createSplTokenHelper(runtime: SolanaClientRuntime, config: SplTo async function prepareTransfer(config: SplTransferPrepareConfig): Promise { const commitment = config.commitment; + const tokenProgram = await resolveTokenProgram(commitment); const lifetime = await resolveLifetime(runtime, commitment, config.lifetime); const { signer, mode } = resolveSigner(config.authority, commitment); const sourceOwner = ensureAddress(config.sourceOwner, signer.address); const destinationOwner = ensureAddress(config.destinationOwner); - const sourceAta = ensureAddress(config.sourceToken, await deriveAssociatedTokenAddress(sourceOwner)); + const sourceAta = ensureAddress( + config.sourceToken, + await deriveAssociatedTokenAddress(sourceOwner, commitment), + ); const destinationAta = ensureAddress( config.destinationToken, - await deriveAssociatedTokenAddress(destinationOwner), + await deriveAssociatedTokenAddress(destinationOwner, commitment), ); const math = await getTokenMath(commitment); @@ -399,6 +442,9 @@ export function createSplTokenHelper(runtime: SolanaClientRuntime, config: SplTo } } + // Use the correct transfer instruction based on token program + const getTransferCheckedInstruction = isToken2022 ? getTransferCheckedToken2022 : getTransferCheckedStandard; + instructionList.push( getTransferCheckedInstruction({ amount, diff --git a/packages/client/src/features/tokenPrograms.ts b/packages/client/src/features/tokenPrograms.ts new file mode 100644 index 0000000..d8e3558 --- /dev/null +++ b/packages/client/src/features/tokenPrograms.ts @@ -0,0 +1,93 @@ +import { type Address, address, type Commitment } from '@solana/kit'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; + +import type { SolanaClientRuntime } from '../rpc/types'; + +/** + * Identifier for supported token programs. + */ +export type TokenProgramId = 'token' | 'token-2022'; + +/** + * Result of detecting which token program owns a mint. + */ +export type TokenProgramDetectionResult = Readonly<{ + /** The program identifier ('token' or 'token-2022'). */ + programId: TokenProgramId; + /** The program address. */ + programAddress: Address; +}>; + +/** + * Map of token program IDs to their addresses. + */ +export const TOKEN_PROGRAMS = { + token: TOKEN_PROGRAM_ADDRESS, + 'token-2022': TOKEN_2022_PROGRAM_ADDRESS, +} as const; + +// Re-export program addresses for convenience +export { TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS }; + +/** + * Detects which token program owns a mint account by examining its owner. + * + * @param runtime - The Solana client runtime with RPC connection. + * @param mint - The mint account address to check. + * @param commitment - Optional commitment level for the RPC call. + * @returns The detected token program information. + * @throws If the mint account doesn't exist or is owned by an unknown program. + * + * @example + * ```ts + * import { detectTokenProgram } from '@solana/client'; + * + * const result = await detectTokenProgram(runtime, mintAddress); + * if (result.programId === 'token-2022') { + * console.log('This is a Token 2022 mint'); + * } + * ``` + */ +export async function detectTokenProgram( + runtime: SolanaClientRuntime, + mint: Address, + commitment?: Commitment, +): Promise { + const { value: accountInfo } = await runtime.rpc + .getAccountInfo(mint, { + commitment, + encoding: 'base64', + }) + .send(); + + if (!accountInfo) { + throw new Error( + `Mint account ${mint} does not exist. ` + + `Provide an explicit tokenProgram when working with non-existent mints.`, + ); + } + + const owner = accountInfo.owner; + + if (owner === TOKEN_PROGRAM_ADDRESS) { + return { programId: 'token', programAddress: address(TOKEN_PROGRAM_ADDRESS) }; + } + + if (owner === TOKEN_2022_PROGRAM_ADDRESS) { + return { programId: 'token-2022', programAddress: address(TOKEN_2022_PROGRAM_ADDRESS) }; + } + + throw new Error(`Mint ${mint} is owned by unknown program ${owner}. Expected Token Program or Token 2022 Program.`); +} + +/** + * Checks if an address is a known token program (Token or Token 2022). + * + * @param programAddress - The program address to check. + * @returns True if the address is a known token program. + */ +export function isKnownTokenProgram(programAddress: Address | string): boolean { + const addrStr = typeof programAddress === 'string' ? programAddress : programAddress; + return addrStr === TOKEN_PROGRAM_ADDRESS || addrStr === TOKEN_2022_PROGRAM_ADDRESS; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c85a100..9e8739f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -69,6 +69,15 @@ export { type WithdrawPrepareConfig, type WithdrawSendOptions, } from './features/stake'; +export { + detectTokenProgram, + isKnownTokenProgram, + TOKEN_2022_PROGRAM_ADDRESS, + TOKEN_PROGRAM_ADDRESS, + TOKEN_PROGRAMS, + type TokenProgramDetectionResult, + type TokenProgramId, +} from './features/tokenPrograms'; export { createTransactionHelper, createTransactionRecipe, diff --git a/packages/react-hooks/README.md b/packages/react-hooks/README.md index ed53e6d..2acb770 100644 --- a/packages/react-hooks/README.md +++ b/packages/react-hooks/README.md @@ -196,6 +196,21 @@ function TokenPanel({ - `owner` — override balance owner (defaults to connected wallet) - `revalidateOnFocus` — refresh when window regains focus - `swr` — additional SWR options +- `config.tokenProgram` — token program: `'auto'` for detection, or explicit address + +### Token 2022 support + +Token 2022 mints are supported via the `tokenProgram` config option: + +```tsx +// Auto-detect Token or Token 2022 +const { balance, send } = useSplToken(mint, { + config: { tokenProgram: "auto" }, +}); + +// Balance and transfers work the same way +

Balance: {balance?.uiAmount ?? "0"}

+``` ### Fetch address lookup tables diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b6317d..5f2909a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ catalogs: '@solana-program/token': specifier: ^0.9.0 version: 0.9.0 + '@solana-program/token-2022': + specifier: ^0.7.0 + version: 0.7.0 '@solana/addresses': specifier: ^5.0.0 version: 5.0.0 @@ -344,6 +347,9 @@ importers: '@solana-program/token': specifier: catalog:solana version: 0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': + specifier: catalog:solana + version: 0.7.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)) '@solana/codecs-strings': specifier: catalog:solana version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -2014,6 +2020,13 @@ packages: peerDependencies: '@solana/kit': ^5.0 + '@solana-program/token-2022@0.7.0': + resolution: {integrity: sha512-ByQdTdbgyhjGf9JklqGRf3u0nbQF5Hbn8Wb2Ir0LZHCgo8lG+2PmBN8UvNY6ONVYb7CjLbApgghvBAEQK1aagg==} + deprecated: This package has been deprecated + peerDependencies: + '@solana/kit': ^5.0 + '@solana/sysvars': ^4.0 + '@solana-program/token@0.9.0': resolution: {integrity: sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==} peerDependencies: @@ -6236,6 +6249,11 @@ snapshots: dependencies: '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/token-2022@0.7.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))': + dependencies: + '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/sysvars': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana-program/token@0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03701ec..1eb51aa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,6 +22,7 @@ catalogs: "@solana-program/system": "^0.10.0" "@solana-program/compute-budget": "^0.11.0" "@solana-program/token": "^0.9.0" + "@solana-program/token-2022": "^0.7.0" "@solana-program/address-lookup-table": "^0.10.0" "@wallet-standard/app": "^1.0.1" "@wallet-standard/base": "^1.1.0"