One command for Claude Code, Cursor, Codex, Windsurf and other agents — installs from the upstream repo (current HEAD; it ships 6 skills — pick token-2022 when prompted).
Drop the mirrored SKILL.md into your skills directory. Global (all projects):
Or scoped to the current project:
The skill is mirrored as plain markdown — tell your agent:
Or have the skills CLI generate a one-shot prompt (pipe it straight into your agent):
Or pull the raw file into a terminal session:
Copy the skill directory straight from GitHub:
Skill contents
Token-2022 (Token Extensions)
SPL Token-2022 is the next-generation token program (TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb). Superset of SPL Token with extensions that add functionality at the mint or account level.
Program IDs:
- Token-2022:
TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb - SPL Token (legacy):
TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
1. Transfer Hooks
Execute custom logic on every token transfer via a CPI to your program.
Anchor Transfer Hook Program
use anchor_lang::prelude::*;
use anchor_spl::token_2022;
use spl_transfer_hook_interface::instruction::TransferHookInstruction;
declare_id!("YourHookProgram111111111111111111111111111");
#[program]
pub mod transfer_hook {
use super::*;
// Called by Token-2022 on every transfer
pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
// Custom logic: logging, royalties, allowlists, etc.
let counter = &mut ctx.accounts.counter;
counter.transfers += 1;
Ok(())
}
// Required: tells Token-2022 what extra accounts to include
pub fn initialize_extra_account_meta_list(
ctx: Context<InitExtraAccountMetaList>,
) -> Result<()> {
let extra_metas = &[
ExtraAccountMeta::new_with_seeds(
&[Seed::Literal { bytes: b"counter".to_vec() }],
false, // is_signer
true, // is_writable
)?,
];
ExtraAccountMetaList::init::<ExecuteInstruction>(
&mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,
extra_metas,
)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct TransferHook<'info> {
#[account(token::mint = mint, token::authority = owner)]
pub source: InterfaceAccount<'info, token_2022::TokenAccount>,
pub mint: InterfaceAccount<'info, token_2022::Mint>,
#[account(token::mint = mint)]
pub destination: InterfaceAccount<'info, token_2022::TokenAccount>,
pub owner: UncheckedAccount<'info>,
/// CHECK: validated by transfer hook interface
pub extra_account_meta_list: UncheckedAccount<'info>,
#[account(mut, seeds = [b"counter"], bump)]
pub counter: Account<'info, TransferCounter>,
}
Client-Side: Adding Extra Accounts
import {
createTransferCheckedWithTransferHookInstruction,
getExtraAccountMetaAddress,
getExtraAccountMetas,
} from "@solana/spl-token";
// Automatically resolves extra accounts from on-chain meta list
const ix = await createTransferCheckedWithTransferHookInstruction(
connection,
sourceAta,
mint,
destinationAta,
owner.publicKey,
amount,
decimals,
[], // multiSigners
"confirmed",
TOKEN_2022_PROGRAM_ID
);
Pitfalls:
- Hook program must implement the
spl-transfer-hook-interfaceexactly — wrong discriminators = silent failures - Extra account meta list must be initialized before any transfers
- Transfer hooks add CU cost to every transfer — keep logic minimal
2. Confidential Transfers
Zero-knowledge proof-based transfers that hide amounts on-chain.
Setup Flow
import {
createMint,
createAccount,
enableConfidentialTransfers,
configureConfidentialTransferAccount,
depositConfidentialTokens,
transferConfidentialTokens,
withdrawConfidentialTokens,
} from "@solana/spl-token";
// 1. Create mint with confidential transfer extension
const mint = await createMint(
connection, payer, mintAuthority, null, 9,
undefined, undefined, TOKEN_2022_PROGRAM_ID,
[{ extensionType: ExtensionType.ConfidentialTransferMint }]
);
// 2. Configure the mint for confidential transfers
await confidentialTransferInitializeMint(
connection, payer, mint,
auditorElGamalPubkey, // optional auditor
);
// 3. Configure each token account
await configureConfidentialTransferAccount(
connection, payer, tokenAccount, owner, TOKEN_2022_PROGRAM_ID
);
// 4. Deposit (public → confidential balance)
await depositConfidentialTokens(
connection, payer, tokenAccount, mint, amount, owner, TOKEN_2022_PROGRAM_ID
);
// 5. Transfer (confidential → confidential)
await transferConfidentialTokens(
connection, payer,
sourceAccount, mint, destinationAccount,
amount, owner, TOKEN_2022_PROGRAM_ID
);
// 6. Withdraw (confidential → public)
await withdrawConfidentialTokens(
connection, payer, tokenAccount, mint, amount, owner, TOKEN_2022_PROGRAM_ID
);
Pitfalls:
- ZK proof generation is CPU-intensive on client — can take seconds
- Both sender and receiver accounts must be configured for confidential transfers
- Auditor key is optional but recommended for compliance
- Pending balance must be applied before it can be transferred
3. Transfer Fee
Automatically collect fees on every transfer.
Creating Mint with Transfer Fee
import {
createInitializeTransferFeeConfigInstruction,
createInitializeMintInstruction,
harvestWithheldTokensToMint,
withdrawWithheldTokensFromMint,
ExtensionType,
getMintLen,
TOKEN_2022_PROGRAM_ID,
} from "@solana/spl-token";
const mintLen = getMintLen([ExtensionType.TransferFeeConfig]);
const mintRent = await connection.getMinimumBalanceForRentExemption(mintLen);
const tx = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: mint.publicKey,
space: mintLen,
lamports: mintRent,
programId: TOKEN_2022_PROGRAM_ID,
}),
createInitializeTransferFeeConfigInstruction(
mint.publicKey,
feeAuthority.publicKey, // can update fee
withdrawAuthority.publicKey, // can collect fees
500, // 5% fee (basis points, max 10000)
BigInt(5_000_000_000), // max fee cap
TOKEN_2022_PROGRAM_ID
),
createInitializeMintInstruction(
mint.publicKey, 9, mintAuthority.publicKey, null,
TOKEN_2022_PROGRAM_ID
)
);
Collecting Fees
// Step 1: Harvest withheld fees from token accounts → mint
await harvestWithheldTokensToMint(
connection, payer, mint.publicKey, [account1, account2],
TOKEN_2022_PROGRAM_ID
);
// Step 2: Withdraw collected fees from mint → destination
await withdrawWithheldTokensFromMint(
connection, payer, mint.publicKey,
feeCollectorAta,
withdrawAuthority,
TOKEN_2022_PROGRAM_ID
);
Pitfalls:
- Fee is withheld in the recipient’s token account, not deducted from sender
transferCheckedis required — plaintransferwill fail- Max fee cap prevents excessive fees on large transfers
4. Interest-Bearing Tokens
Display an interest-accruing UI balance without on-chain token minting.
import {
createInitializeInterestBearingMintInstruction,
amountToUiAmount,
} from "@solana/spl-token";
// Initialize mint with interest rate (basis points)
const ix = createInitializeInterestBearingMintInstruction(
mint.publicKey,
rateAuthority.publicKey,
350, // 3.5% annual rate in basis points
TOKEN_2022_PROGRAM_ID
);
// Calculate display amount with accrued interest
const uiAmount = amountToUiAmount(connection, mint.publicKey, rawAmount);
Pitfalls:
- Interest is cosmetic — no new tokens are minted. Supply doesn’t change
- Only affects
amountToUiAmount/uiAmountToAmountcalculations - Rate authority can update the rate; set to
nullto lock it
5. Metadata Pointer + Token Metadata
Store token metadata directly on the mint — no Metaplex needed.
import {
createInitializeMetadataPointerInstruction,
createInitializeMintInstruction,
TYPE_SIZE, LENGTH_SIZE,
} from "@solana/spl-token";
import {
createInitializeInstruction,
createUpdateFieldInstruction,
pack,
} from "@solana/spl-token-metadata";
// Metadata points to the mint itself
const metadataPointerIx = createInitializeMetadataPointerInstruction(
mint.publicKey,
updateAuthority.publicKey,
mint.publicKey, // metadata lives on the mint
TOKEN_2022_PROGRAM_ID
);
const metadata = {
mint: mint.publicKey,
name: "My Token",
symbol: "MTK",
uri: "https://example.com/metadata.json",
additionalMetadata: [["description", "A cool token"]],
};
const metadataLen = TYPE_SIZE + LENGTH_SIZE + pack(metadata).length;
const mintLen = getMintLen([ExtensionType.MetadataPointer]) + metadataLen;
const tx = new Transaction().add(
SystemProgram.createAccount({ /* ... */ space: mintLen, /* ... */ }),
metadataPointerIx,
createInitializeMintInstruction(mint.publicKey, 9, mintAuth, null, TOKEN_2022_PROGRAM_ID),
createInitializeInstruction({
programId: TOKEN_2022_PROGRAM_ID,
mint: mint.publicKey,
metadata: mint.publicKey,
name: metadata.name,
symbol: metadata.symbol,
uri: metadata.uri,
mintAuthority: mintAuth,
updateAuthority: updateAuthority.publicKey,
}),
createUpdateFieldInstruction({
programId: TOKEN_2022_PROGRAM_ID,
metadata: mint.publicKey,
updateAuthority: updateAuthority.publicKey,
field: "description",
value: "A cool token",
})
);
Pitfalls:
- Metadata pointer must be initialized BEFORE
InitializeMint - Account space must include metadata length — calculate with
pack() additionalMetadatais key-value pairs, not arbitrary JSON
6. Default Account State
All new token accounts start frozen — useful for regulated tokens.
import {
createInitializeDefaultAccountStateInstruction,
AccountState,
} from "@solana/spl-token";
const ix = createInitializeDefaultAccountStateInstruction(
mint.publicKey,
AccountState.Frozen,
TOKEN_2022_PROGRAM_ID
);
// Add before InitializeMint in transaction
Pitfalls:
- Requires freeze authority on the mint to thaw accounts
- Every new ATA will be frozen — ensure your onboarding flow thaws after KYC/approval
7. Immutable Owner
Prevents the token account owner from being reassigned. ATAs have this by default.
import { createInitializeImmutableOwnerInstruction } from "@solana/spl-token";
// Add before InitializeAccount instruction
const ix = createInitializeImmutableOwnerInstruction(
tokenAccount.publicKey,
TOKEN_2022_PROGRAM_ID
);
Pitfalls:
- Already default for Associated Token Accounts under Token-2022
- Only needed for manually created accounts
- Cannot be added after account initialization
8. CPI Guard
Prevents token account from being manipulated via CPI (cross-program invocation).
import { createEnableCpiGuardInstruction } from "@solana/spl-token";
const ix = createEnableCpiGuardInstruction(
tokenAccount,
owner.publicKey,
[],
TOKEN_2022_PROGRAM_ID
);
When enabled, CPI calls cannot:
- Transfer tokens from this account
- Burn tokens from this account
- Approve a delegate
- Close the account
- Set the close authority
Pitfalls:
- User must disable CPI guard before interacting with DeFi protocols that use CPI
- Only the account owner (via top-level instruction) can disable it
9. Permanent Delegate
A delegate that can never be revoked — can transfer or burn any amount.
import { createInitializePermanentDelegateInstruction } from "@solana/spl-token";
const ix = createInitializePermanentDelegateInstruction(
mint.publicKey,
permanentDelegate.publicKey,
TOKEN_2022_PROGRAM_ID
);
// Add before InitializeMint
Use cases: Compliance (freeze/seize), recovery mechanisms, auto-revocation of licenses.
Pitfalls:
- Extremely powerful — the delegate can drain any token account for this mint
- Users may reject tokens with permanent delegates — impacts trust
- Cannot be removed once set
10. Non-Transferable Tokens (Soulbound)
Tokens that cannot be transferred after minting — only burn is allowed.
import { createInitializeNonTransferableMintInstruction } from "@solana/spl-token";
const ix = createInitializeNonTransferableMintInstruction(
mint.publicKey,
TOKEN_2022_PROGRAM_ID
);
// Add before InitializeMint
Use cases: Achievements, certifications, reputation scores, membership badges.
Pitfalls:
- Tokens can still be burned — use with freeze authority if you need to prevent that
- Cannot be combined with transfer hooks (transfers are blocked entirely)
11. Group / Member Pointer
Create on-chain token collections — a group mint points to group data, member mints point back to their group.
import {
createInitializeGroupPointerInstruction,
createInitializeMemberPointerInstruction,
} from "@solana/spl-token";
// Group mint
const groupIx = createInitializeGroupPointerInstruction(
groupMint.publicKey,
groupAuthority.publicKey,
groupMint.publicKey, // group data on the mint itself
TOKEN_2022_PROGRAM_ID
);
// Member mint
const memberIx = createInitializeMemberPointerInstruction(
memberMint.publicKey,
memberAuthority.publicKey,
memberMint.publicKey,
TOKEN_2022_PROGRAM_ID
);
Pitfalls:
- Relatively new — tooling support may lag behind other extensions
- Group size must be updated when adding members
12. Required Memo on Transfer
Every transfer to this account must include a memo instruction in the same transaction.
import { createEnableRequiredMemoTransfersInstruction } from "@solana/spl-token";
const ix = createEnableRequiredMemoTransfersInstruction(
tokenAccount,
owner.publicKey,
[],
TOKEN_2022_PROGRAM_ID
);
Sending a transfer:
import { createMemoInstruction } from "@solana/spl-memo";
const tx = new Transaction().add(
createMemoInstruction("Payment for invoice #1234", [sender.publicKey]),
createTransferCheckedInstruction(
sourceAta, mint, destinationAta, sender.publicKey,
amount, decimals, [], TOKEN_2022_PROGRAM_ID
)
);
Pitfalls:
- Memo instruction must come BEFORE the transfer in the transaction
- Breaks compatibility with programs that don’t include memos
Detecting Extensions
Check Mint Extensions
import {
getMint,
getExtensionTypes,
getExtensionData,
ExtensionType,
TOKEN_2022_PROGRAM_ID,
} from "@solana/spl-token";
const mintInfo = await getMint(connection, mintPubkey, "confirmed", TOKEN_2022_PROGRAM_ID);
const extensions = getExtensionTypes(mintInfo.tlvData);
// Check specific extension
const hasTransferFee = extensions.includes(ExtensionType.TransferFeeConfig);
const hasTransferHook = extensions.includes(ExtensionType.TransferHook);
const hasMetadata = extensions.includes(ExtensionType.MetadataPointer);
// Get extension data
if (hasTransferFee) {
const feeConfig = getTransferFeeConfig(mintInfo);
console.log(`Fee: ${feeConfig.newerTransferFee.transferFeeBasisPoints} bps`);
}
Check Account Extensions
import { getAccount, getExtensionTypes } from "@solana/spl-token";
const accountInfo = await getAccount(
connection, tokenAccount, "confirmed", TOKEN_2022_PROGRAM_ID
);
const extensions = getExtensionTypes(accountInfo.tlvData);
const hasCpiGuard = extensions.includes(ExtensionType.CpiGuard);
Frontend Handling
Transfer Hook Extra Accounts
import { addExtraAccountMetasForExecute } from "@solana/spl-token";
// Build a basic transfer instruction, then add extra accounts
const ix = createTransferCheckedInstruction(
source, mint, destination, owner, amount, decimals, [], TOKEN_2022_PROGRAM_ID
);
// Resolves and appends extra accounts from on-chain meta list
await addExtraAccountMetasForExecute(
connection, ix, TOKEN_2022_PROGRAM_ID,
source, mint, destination, owner, amount
);
Displaying Token Metadata
import { getTokenMetadata } from "@solana/spl-token-metadata";
const metadata = await getTokenMetadata(connection, mintPubkey);
if (metadata) {
console.log(metadata.name, metadata.symbol, metadata.uri);
// Additional metadata as Map<string, string>
for (const [key, value] of metadata.additionalMetadata) {
console.log(`${key}: ${value}`);
}
}
Testing with LiteSVM
use litesvm::LiteSVM;
#[test]
fn test_token_2022_transfer_fee() {
let mut svm = LiteSVM::new();
// Load Token-2022 program
svm.add_program_from_file(
spl_token_2022::ID,
"path/to/spl_token_2022.so"
);
let payer = Keypair::new();
svm.airdrop(&payer.pubkey(), 10_000_000_000).unwrap();
// Create mint with transfer fee extension
let mint = Keypair::new();
let space = ExtensionType::try_calculate_account_len::<Mint>(
&[ExtensionType::TransferFeeConfig]
).unwrap();
let create_ix = system_instruction::create_account(
&payer.pubkey(), &mint.pubkey(),
svm.minimum_balance_for_rent_exemption(space),
space as u64, &spl_token_2022::ID,
);
let fee_ix = spl_token_2022::extension::transfer_fee::instruction::initialize_transfer_fee_config(
&spl_token_2022::ID, &mint.pubkey(),
Some(&fee_authority.pubkey()),
Some(&withdraw_authority.pubkey()),
500, // 5%
5_000_000_000, // max fee
).unwrap();
let init_ix = spl_token_2022::instruction::initialize_mint(
&spl_token_2022::ID, &mint.pubkey(),
&mint_authority.pubkey(), None, 9,
).unwrap();
let tx = Transaction::new_signed_with_payer(
&[create_ix, fee_ix, init_ix],
Some(&payer.pubkey()),
&[&payer, &mint],
svm.latest_blockhash(),
);
svm.send_transaction(tx).unwrap();
}
Anchor Test Pattern
import { startAnchor } from "solana-bankrun";
import { BankrunProvider } from "anchor-bankrun";
const context = await startAnchor(".", [], []);
const provider = new BankrunProvider(context);
// Token-2022 is available by default in bankrun
// Create mints and accounts using @solana/spl-token with TOKEN_2022_PROGRAM_ID
Migration from SPL Token
| SPL Token | Token-2022 | Notes |
|---|---|---|
TOKEN_PROGRAM_ID | TOKEN_2022_PROGRAM_ID | Different program ID |
createTransferInstruction | createTransferCheckedInstruction | Must use checked variant |
getMint(conn, mint) | getMint(conn, mint, commitment, TOKEN_2022_PROGRAM_ID) | Pass program ID explicitly |
getAccount(conn, addr) | getAccount(conn, addr, commitment, TOKEN_2022_PROGRAM_ID) | Pass program ID explicitly |
getAssociatedTokenAddress(mint, owner) | getAssociatedTokenAddress(mint, owner, false, TOKEN_2022_PROGRAM_ID) | Different ATA derivation |
| Fixed account size (165 bytes) | Variable size (extensions) | Use getMintLen([...extensions]) |
Anchor CPI to Token-2022
use anchor_spl::token_2022::{self, Token2022, TransferChecked};
#[derive(Accounts)]
pub struct Pay<'info> {
#[account(mut)]
pub from: InterfaceAccount<'info, TokenAccount>,
#[account(mut)]
pub to: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token2022>,
}
pub fn pay(ctx: Context<Pay>, amount: u64) -> Result<()> {
token_2022::transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.to.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
),
amount,
ctx.accounts.mint.decimals,
)
}
Supporting Both Token Programs
use anchor_spl::token_interface::{self, TokenInterface, TokenAccount, Mint, TransferChecked};
#[derive(Accounts)]
pub struct Pay<'info> {
#[account(mut)]
pub from: InterfaceAccount<'info, TokenAccount>,
#[account(mut)]
pub to: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
pub authority: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>, // accepts both
}
pub fn pay(ctx: Context<Pay>, amount: u64) -> Result<()> {
token_interface::transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.to.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
),
amount,
ctx.accounts.mint.decimals,
)
}
Use Interface<'info, TokenInterface> to accept both SPL Token and Token-2022 in the same instruction. This is the recommended pattern for new programs.