Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/token-2022-support.md
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions apps/docs/content/docs/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions apps/docs/content/docs/react-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<p>Balance: {balance?.uiAmount ?? "0"}</p>
<button onClick={() => send({ amount: 1, destinationOwner: recipient })}>
Send
</button>
</div>
);
}
```

### useWrapSol

Expand Down
28 changes: 28 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 58 additions & 12 deletions packages/client/src/features/spl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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';
}>;

/**
Expand Down Expand Up @@ -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<Address>;
deriveAssociatedTokenAddress(owner: Address | string, commitment?: Commitment): Promise<Address>;
/**
* Fetches the token balance for an owner's Associated Token Account.
* Returns balance info including whether the account exists.
Expand Down Expand Up @@ -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<Address> {
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<number> {
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;
Expand All @@ -323,7 +361,8 @@ export function createSplTokenHelper(runtime: SolanaClientRuntime, config: SplTo
return cachedMath;
}

async function deriveAssociatedTokenAddress(owner: Address | string): Promise<Address> {
async function deriveAssociatedTokenAddress(owner: Address | string, commitment?: Commitment): Promise<Address> {
const tokenProgram = await resolveTokenProgram(commitment);
const [ata] = await findAssociatedTokenPda({
mint: mintAddress,
owner: ensureAddress(owner),
Expand All @@ -333,7 +372,7 @@ export function createSplTokenHelper(runtime: SolanaClientRuntime, config: SplTo
}

async function fetchBalance(owner: Address | string, commitment?: Commitment): Promise<SplTokenBalance> {
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();
Expand All @@ -360,15 +399,19 @@ export function createSplTokenHelper(runtime: SolanaClientRuntime, config: SplTo

async function prepareTransfer(config: SplTransferPrepareConfig): Promise<PreparedSplTransfer> {
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);
Expand Down Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions packages/client/src/features/tokenPrograms.ts
Original file line number Diff line number Diff line change
@@ -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<TokenProgramDetectionResult> {
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;
}
9 changes: 9 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading