Skip to main content

Wallet Guide

This guide is meant for wallet developers who want to support Token-2022.

Since wallets have very different internals for managing token account state and connections to blockchains, this guide will focus on the very specific changes required, without only vague mentions of code design.

Motivation

Wallet developers are accustomed to only including one token program used for all tokens.

To properly support Token-2022, wallet developers must make code changes.

Important note: if you do not wish to support Token-2022, you do not need to do anything. The wallet will not load Token-2022 accounts, and transactions created by the wallet will fail loudly if using Token-2022 incorrectly.

Most likely, transactions will fail with ProgramError::IncorrectProgramId when trying to target the Token program with Token-2022 accounts.

Prerequisites

When testing locally, be sure to use at least solana-test-validator version 1.14.17, which includes the Token-2022 program by default. This comes bundled with version 2.3.0 of the spl-token CLI, which also supports Token-2022.

Setup

You'll need some Token-2022 tokens for testing. First, create a mint with an extension. We'll use the "Mint Close Authority" extension:

$ spl-token -ul create-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb --enable-close
Creating token E5SUrbnx7bMBp3bRdMWNCFS3FXp5VpvFDdNFp8rjrMLM under program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb

Address: E5SUrbnx7bMBp3bRdMWNCFS3FXp5VpvFDdNFp8rjrMLM
Decimals: 9

Signature: 2dYhT1M3dHjbGd9GFCFPXmHMtjujXBGhM8b5wBkx3mtUptQa5U9jjRTWHCEmUQnv8XLt2x5BHdbDUkZpNJFqfJn1

The extension is important because it will test that your wallet properly handles larger mint accounts.

Next, create an account for your test wallet:

$ spl-token -ul create-account E5SUrbnx7bMBp3bRdMWNCFS3FXp5VpvFDdNFp8rjrMLM --owner <TEST_WALLET_ADDRESS> --fee-payer <FEE_PAYER_KEYPAIR>
Creating account 4L45ZpFS6dqTyLMofmQZ9yuTqYvQrfCJfWL2xAjd5WDW

Signature: 5Cjvvzid7w2tNZojrWVCmZ2MFiezxxnWgJHLJKkvJNByZU2sLN97y85CghxHwPaVf5d5pJAcDV9R4N1MNigAbBMN

With the --owner parameter, the new account is an associated token account, which includes the "Immutable Owner" account extension. This way, you'll also test larger token accounts.

Finally, mint some tokens:

$ spl-token -ul mint E5SUrbnx7bMBp3bRdMWNCFS3FXp5VpvFDdNFp8rjrMLM 100000 4L45ZpFS6dqTyLMofmQZ9yuTqYvQrfCJfWL2xAjd5WDW
Minting 100000 tokens
Token: E5SUrbnx7bMBp3bRdMWNCFS3FXp5VpvFDdNFp8rjrMLM
Recipient: 4L45ZpFS6dqTyLMofmQZ9yuTqYvQrfCJfWL2xAjd5WDW

Signature: 43rsisVeLKjBCgLruwTFJXtGTBgwyfpLjwm44dY2YLHH9WJaazEvkyYGdq6omqs4thRfCS4G8z4KqzEGRP2xoMo9

It's also helpful for your test wallet to have some SOL, so be sure to transfer some:

$ solana -ul transfer <TEST_WALLET_ADDRESS> 10 --allow-unfunded-recipient
Signature: 5A4MbdMTgGiV7hzLesKbzmrPSCvYPG15e1bg3d7dViqMaPbZrdJweKSuY1BQAfq245RMMYeGudxyKQYkgKoGT1Ui

Finally, you can save all of these accounts in a directory to be re-used for testing:

$ mkdir test-accounts
$ solana -ul account --output-file test-accounts/token-account.json --output json 4L45ZpFS6dqTyLMofmQZ9yuTqYvQrfCJfWL2xAjd5WDW
... output truncated ...
$ solana -ul account --output-file test-accounts/mint.json --output json E5SUrbnx7bMBp3bRdMWNCFS3FXp5VpvFDdNFp8rjrMLM
... output truncated ...
$ solana -ul account --output-file test-accounts/wallet.json --output json <TEST_WALLET_ADDRESS>

This way, whenever you want to restart your test validator, you can simply run:

$ solana-test-validator -r --account-dir test-accounts

Structure of this Guide

We'll go through the required code changes to support Token-2022 in your wallet, using only little code snippets. This work was done for the Backpack wallet in PR #3976, but as mentioned earlier, the actual code changes may look very different for your wallet.

Part I: Fetch Token-2022 Accounts

In addition to normal Token accounts, your wallet must also fetch Token-2022 accounts. Typically, wallets use the getTokenAccountsByOwner RPC endpoint once to fetch the accounts.

For Token-2022, you simply need to add one more call to get the additional accounts:

import { Connection, PublicKey } from '@solana/web3.js';

const TOKEN_PROGRAM_ID = new PublicKey(
'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
);
const TOKEN_2022_PROGRAM_ID = new PublicKey(
'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'
);
const walletPublicKey = new PublicKey('11111111111111111111111111111111'); // insert your key
const connection = new Connection('http://127.0.0.1:8899', 'confirmed');

const tokenAccounts = await connection.getTokenAccountsByOwner(
walletPublicKey, { programId: TOKEN_PROGRAM_ID }
);
const token2022Accounts = await connection.getTokenAccountsByOwner(
walletPublicKey, { programId: TOKEN_2022_PROGRAM_ID }
);

Merge the two responses, and you're good to go! If you can see your test account, then you've done it correctly.

If there are issues, your wallet may be deserializing the token account too strictly, so be sure to relax any restriction that the data size must be equal to 165 bytes.

Part II: Use the Token Program Id for Instructions

If you try to transfer or burn a Token-2022 token, you will likely receive an error because the wallet is trying to send an instruction to Token instead of Token-2022.

Here are two possible ways to resolve the problem.

Option 1: Store the token account's owner during fetch

In the first part, we fetched all of the token accounts and threw away the program id associated with the account. Instead of always targeting the Token program, we need to target the right program for that token.

If we store the program id for each token account, then we can re-use that information when we need to transfer or burn.

import { Connection, PublicKey } from '@solana/web3.js';
import { createTransferInstruction } from '@solana/spl-token';

const TOKEN_PROGRAM_ID = new PublicKey(
'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
);
const TOKEN_2022_PROGRAM_ID = new PublicKey(
'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'
);
const walletPublicKey = new PublicKey('11111111111111111111111111111111'); // insert your key
const connection = new Connection('http://127.0.0.1:8899', 'confirmed');

const tokenAccounts = await connection.getTokenAccountsByOwner(
walletPublicKey, { programId: TOKEN_PROGRAM_ID }
);
const token2022Accounts = await connection.getTokenAccountsByOwner(
walletPublicKey, { programId: TOKEN_2022_PROGRAM_ID }
);
const accountsWithProgramId = [...tokenAccounts.value, ...token2022Accounts.value].map(
({ account, pubkey }) =>
{
account,
pubkey,
programId: account.data.program === 'spl-token' ? TOKEN_PROGRAM_ID : TOKEN_2022_PROGRAM_ID,
},
);

// later on...
const accountWithProgramId = accountsWithProgramId[0];
const instruction = createTransferInstruction(
accountWithProgramId.pubkey, // source
accountWithProgramId.pubkey, // destination
walletPublicKey, // owner
1, // amount
[], // multisigners
accountWithProgramId.programId, // token program id
);

Option 2: Fetch the program owner before transfer / burn

This approach introduces one more network call, but may be simpler to integrate. Before creating an instruction, you can fetch the mint, source account, or destination account from the network, and pull out its owner field.

import { Connection, PublicKey } from '@solana/web3.js';

const connection = new Connection('http://127.0.0.1:8899', 'confirmed');
const accountPublicKey = new PublicKey('11111111111111111111111111111111'); // insert your account key here
const accountInfo = await connection.getParsedAccountInfo(accountPublicKey);
if (accountInfo.value === null) {
throw new Error('Account not found');
}
const programId = accountInfo.value.owner;

Part III: Use the Token Program Id for Associated Token Accounts

Whenever we derive an associated token account, we must use the correct token program id. Currently, most implementations hardcode the token program id. Instead, you must add the program id as a parameter:

import { PublicKey } from '@solana/web3.js';

const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
);

function associatedTokenAccountAddress(
mint: PublicKey,
wallet: PublicKey,
programId: PublicKey,
): PublicKey {
return PublicKey.findProgramAddressSync(
[wallet.toBuffer(), programId.toBuffer(), mint.toBuffer()],
ASSOCIATED_TOKEN_PROGRAM_ID
)[0];
}

If you're creating associated token accounts, you'll also need to pass the token program id, which currently defaults to TOKEN_PROGRAM_ID:

import { Connection, PublicKey } from '@solana/web3.js';
import { createAssociatedTokenAccountInstruction } from '@solana/spl-token';

const tokenProgramId = new PublicKey(
'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'
); // either `Tokenz...` or `Tokenkeg...`
const wallet = new PublicKey('11111111111111111111111111111111'); // insert your key
const mint = new PublicKey('11111111111111111111111111111111'); // insert mint key
const associatedTokenAccount = associatedTokenAccountAddress(mint, wallet, tokenProgramId);

const instruction = createAssociatedTokenAccountInstruction(
wallet, // payer
associatedTokenAccount, // associated token account
wallet, // owner
tokenProgramId, // token program id
);

With these three parts done, your wallet will provide basic support for Token-2022!