19 min read
Overview
In April 2024, Anchor released version 0.30.0, which includes several significant changes and developer improvements. One notable change is the inclusion of token account constraints for Token Extensions (Token 2022). This guide will walk you through what this means for your Solana projects, and we will walk through Anchor's sample program to create and validate tokens with Token Extensions.
What You Will Do
- Learn about the new token constraints in Anchor
- Review and recreate a sample program that creates and validates tokens with Token Extensions
What You Will Need
- Basic experience with building in Anchor (Guide: Getting Started with Anchor)
- Experience with Solana Token Extensions
- Experience with Rust programming language
- Basic knowledge of the JavaScript/TypeScript
- Anchor v.0.30.0 or later installed
Dependencies Used in this Guide
Dependency | Version |
---|---|
solana-cli | 1.18.8 |
anchor-cli | 0.30.0 |
anchor-lang | 0.30.0 |
anchor-spl | 0.30.0 |
Token Extensions Recap
Token Extensions (also known as Token-2022) are an advanced token program on Solana that extends the capabilities of the original SPL Token Program. It is designed to offer developers enhanced flexibility and additional functionalities without compromising the safety of current tokens. Many extensions are available, including metadata, transfer fees, transfer hooks, and more. Before you continue, familiarize yourself with Token Extensions and how they work.
Token Extensions in Anchor
As of Anchor 0.30.0, you can now create and validate tokens with Token Extensions with Anchor constraints.
Extension | Constraints |
---|---|
group_pointer | authority |
group_address | |
group_member_pointer | authority |
member_address | |
metadata_pointer | authority |
metadata_address | |
close_authority | authority |
permanent_delegate | delegate |
transfer_hook | authority |
program_id |
Extension constraints get used in your accounts context structs using the tag extension
followed by ::
and the extension name and then ::
and the constraint name. For example:
extension::group_pointer::authority = YOUR_AUTH.key()
They can be used with the init
constraint when creating a new token or without the init
constraint when validating an existing token.
Anchor has released a sample program that demonstrates how to create and validate tokens with Token Extensions using the new constraints. Let's make a new Anchor project and add the sample program to see how it works.
Create a Token Extension Program with Anchor
Initiate a New Anchor Project
Make sure you have Anchor v.0.30.0 or later installed. Older versions of Anchor will not work with the new constraints. You can check your Anchor version by running anchor --version
. If you have Anchor Version Manager installed, you can upgrade your Anchor version by running avm update
.
Open your terminal and initiate a new Anchor project by running the following command:
anchor init token-extensions
This will create a new project directory named token-extensions
with the necessary files and directories to get started. Change into the new directory:
cd token-extensions
Update Cargo.toml
First, let's import our necessary dependencies. Open the Cargo.toml
file in your programs
directory (programs/token-extensions/Cargo.toml) and add the following dependencies:
[dependencies]
anchor-lang = { version = "0.30.0", features = ["init-if-needed"] }
anchor-spl = "0.30.0"
spl-tlv-account-resolution = "0.6.3"
spl-transfer-hook-interface = "0.6.3"
spl-type-length-value = "0.4.3"
spl-pod = "0.2.2"
There are a few new dependencies here that we will need for interacting with the Token Extensions program:
spl-tlv-account-resolution
- A library for encoding and decoding TLV (Type-Length-Value) data in accounts.spl-transfer-hook-interface
- A library for implementing transfer hooks.spl-type-length-value
- A library for encoding and decoding TLV data.spl-pod
- A library for encoding and decoding Plain Old Data (POD) data.
Next, let's update our [features]
. We need to be mindful of a new feature in Anchor 0.30.0: IDL (Interface Definition Language) builds. The "idl-build" feature is now required in your program's Cargo.toml definition for the IDL generation to work and is required for all crates used to generate your IDL. In our case, we must include the idl-build
feature in the anchor-spl
dependency. Update the idl-build
feature in the Cargo.toml
file to include anchor-spl
:
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
Great! Now, we can move on to the next step.
Add Token Extension Utilities
Create a new file, utils.rs
, inside your program src
directory (token-extensions/programs/token-extensions/src/utils.rs
). This file will contain utility functions that we will use to interact with the Token Extensions program. Add the following imports to the utils.rs
file:
use anchor_lang::{
prelude::Result,
solana_program::{
account_info::AccountInfo,
program::invoke,
pubkey::Pubkey,
rent::Rent,
system_instruction::transfer,
sysvar::Sysvar,
},
Lamports,
};
use anchor_spl::token_interface::spl_token_2022::{
extension::{BaseStateWithExtensions, Extension, StateWithExtensions},
solana_zk_token_sdk::zk_token_proof_instruction::Pod,
state::Mint,
};
use spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList};
use spl_type_length_value::variable_len_pack::VariableLenPack;
pub const APPROVE_ACCOUNT_SEED: &[u8] = b"approve-account";
pub const META_LIST_ACCOUNT_SEED: &[u8] = b"extra-account-metas";
And then define the following utility functions below the imports:
pub fn update_account_lamports_to_minimum_balance<'info>(
account: AccountInfo<'info>,
payer: AccountInfo<'info>,
system_program: AccountInfo<'info>,
) -> Result<()> {
let extra_lamports = Rent::get()?.minimum_balance(account.data_len()) - account.get_lamports();
if extra_lamports > 0 {
invoke(
&transfer(payer.key, account.key, extra_lamports),
&[payer, account, system_program],
)?;
}
Ok(())
}
pub fn get_mint_extensible_extension_data<T: Extension + VariableLenPack>(
account: &mut AccountInfo,
) -> Result<T> {
let mint_data = account.data.borrow();
let mint_with_extension = StateWithExtensions::<Mint>::unpack(&mint_data)?;
let extension_data = mint_with_extension.get_variable_len_extension::<T>()?;
Ok(extension_data)
}
pub fn get_mint_extension_data<T: Extension + Pod>(account: &mut AccountInfo) -> Result<T> {
let mint_data = account.data.borrow();
let mint_with_extension = StateWithExtensions::<Mint>::unpack(&mint_data)?;
let extension_data = *mint_with_extension.get_extension::<T>()?;
Ok(extension_data)
}
pub fn get_meta_list(approve_account: Option<Pubkey>) -> Vec<ExtraAccountMeta> {
if let Some(approve_account) = approve_account {
return vec![ExtraAccountMeta {
discriminator: 0,
address_config: approve_account.to_bytes(),
is_signer: false.into(),
is_writable: true.into(),
}];
}
vec![]
}
pub fn get_meta_list_size(approve_account: Option<Pubkey>) -> usize {
// safe because it's either 0 or 1
ExtraAccountMetaList::size_of(get_meta_list(approve_account).len()).unwrap()
}
Here's what each of these will do:
update_account_lamports_to_minimum_balance
- This function will update the account's lamports to the minimum balance required by the rent sysvar. We can use this to ensure that the account has enough lamports to be rent-exempt, based on its data length, after defining its extensions.get_mint_extensible_extension_data
- This function will get the extension data for an extensible (or variable-length) extension type from a mint account.get_mint_extension_data
- This function will get the extension data for a fixed-length extension type from a mint account.get_meta_list
- This function will get the extra account metas for the approve account (used for transfer hooks).get_meta_list_size
- This function will get the size of the extra account metas for the approve account.
Ref: Anchor Token Extensions Sample Program
Update lib.rs
Next, let's update the lib.rs
file in the programs/token-extensions/src
directory. Replace the contents of the lib.rs
file with the following code (Make sure to replace YOUR_PROGRAM_ID_HERE
with your program ID):
use anchor_lang::prelude::*;
pub mod instructions;
pub mod utils;
pub use instructions::*;
pub use utils::*;
declare_id!("YOUR_PROGRAM_ID_HERE"); // Replace with your program ID
#[program]
pub mod token_extensions {
use super::*;
pub fn create_mint_account(
ctx: Context<CreateMintAccount>,
args: CreateMintAccountArgs,
) -> Result<()> {
instructions::handler(ctx, args)
}
pub fn check_mint_extensions_constraints(
_ctx: Context<CheckMintExtensionConstraints>,
) -> Result<()> {
Ok(())
}
}
This is the main entry point for our program. It declares the program ID and imports the instructions and utility functions we defined earlier. We will be defining two program functions: create_mint_account
and check_mint_extensions_constraints
. Let's define those now.
Create New Token Instruction
Create a new file, instructions.rs
inside your program src
directory (token-extensions/programs/token-extensions/src/instructions.rs
). This file will contain the instruction handlers for our program. Add the following imports to the instructions.rs
file:
use anchor_lang::{prelude::*, solana_program::entrypoint::ProgramResult};
use anchor_spl::{
associated_token::AssociatedToken,
token_2022::spl_token_2022::extension::{
group_member_pointer::GroupMemberPointer, metadata_pointer::MetadataPointer,
mint_close_authority::MintCloseAuthority, permanent_delegate::PermanentDelegate,
transfer_hook::TransferHook,
},
token_interface::{
spl_token_metadata_interface::state::TokenMetadata, token_metadata_initialize, Mint,
Token2022, TokenAccount, TokenMetadataInitialize,
},
};
use spl_pod::optional_keys::OptionalNonZeroPubkey;
use crate::{
get_meta_list_size, get_mint_extensible_extension_data, get_mint_extension_data,
update_account_lamports_to_minimum_balance, META_LIST_ACCOUNT_SEED,
};
Most notable/relevant here is the inclusion of anchor_spl::token_2022::spl_token_2022::extension
and anchor_spl::token_interface
, which will allow us to interact with the Token Extensions program.
Let's define our CreateMintAccount
accounts context struct and arguments struct. Add the following code to the instructions.rs
file:
#[derive(AnchorDeserialize, AnchorSerialize)]
pub struct CreateMintAccountArgs {
pub name: String,
pub symbol: String,
pub uri: String,
}
#[derive(Accounts)]
#[instruction(args: CreateMintAccountArgs)]
pub struct CreateMintAccount<'info> {
#[account(mut)]
pub payer: Signer<'info>,
#[account(mut)]
/// CHECK: can be any account
pub authority: Signer<'info>,
#[account()]
/// CHECK: can be any account
pub receiver: UncheckedAccount<'info>,
#[account(
init,
signer,
payer = payer,
mint::token_program = token_program,
mint::decimals = 0,
mint::authority = authority,
mint::freeze_authority = authority,
extensions::metadata_pointer::authority = authority,
extensions::metadata_pointer::metadata_address = mint,
extensions::group_member_pointer::authority = authority,
extensions::group_member_pointer::member_address = mint,
extensions::transfer_hook::authority = authority,
extensions::transfer_hook::program_id = crate::ID,
extensions::close_authority::authority = authority,
extensions::permanent_delegate::delegate = authority,
)]
pub mint: Box<InterfaceAccount<'info, Mint>>,
#[account(
init,
payer = payer,
associated_token::token_program = token_program,
associated_token::mint = mint,
associated_token::authority = receiver,
)]
pub mint_token_account: Box<InterfaceAccount<'info, TokenAccount>>,
/// CHECK: This account's data is a buffer of TLV data
#[account(
init,
space = get_meta_list_size(None),
seeds = [META_LIST_ACCOUNT_SEED, mint.key().as_ref()],
bump,
payer = payer,
)]
pub extra_metas_account: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Program<'info, Token2022>,
}
First and foremost, note that we are defining our token_program
as Program<'info, Token2022>
. This program will allow us to interact with Token Extensions. If you try to use the original TokenProgram
, these extensions will not work.
The most notable changes lie in the mint
account initialization. We are now defining the extensions for the mint account using the new constraints. If you have used Anchor a bit in the past, this should feel pretty familiar to you. We are initiating our mint with a number of extension
constraints, for example, extensions::metadata_pointer::metadata_address = mint
is setting the metadata address to itself. Each follows the same pattern:
extensions::<extension_name>::<constraint_name> = value
Because we are defining the mint with the transfer_hook
extension, we also need to create our extra_metas_account
, which will hold the extra account metas for the approved account. We will cover this account and transfer hooks in another guide, but for now, we are just setting the space for the account so you can see how it is done.
Let's create a function to initialize our mint's metadata. Add the following implementation of CreateMintAccount
to the instructions.rs
file:
impl<'info> CreateMintAccount<'info> {
fn initialize_token_metadata(
&self,
name: String,
symbol: String,
uri: String,
) -> ProgramResult {
let cpi_accounts = TokenMetadataInitialize {
token_program_id: self.token_program.to_account_info(),
mint: self.mint.to_account_info(),
metadata: self.mint.to_account_info(), // metadata account is the mint, since data is stored in mint
mint_authority: self.authority.to_account_info(),
update_authority: self.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(self.token_program.to_account_info(), cpi_accounts);
token_metadata_initialize(cpi_ctx, name, symbol, uri)?;
Ok(())
}
}
This is a simple CPI to the Token Program to initialize the metadata for our mint. We are passing in the name
, symbol
, and uri
for the metadata.
Finally, let's define the handler for our CreateMintAccount
instruction. Add the following code to the instructions.rs
file:
pub fn handler(ctx: Context<CreateMintAccount>, args: CreateMintAccountArgs) -> Result<()> {
ctx.accounts.initialize_token_metadata(
args.name.clone(),
args.symbol.clone(),
args.uri.clone(),
)?;
ctx.accounts.mint.reload()?;
update_account_lamports_to_minimum_balance(
ctx.accounts.mint.to_account_info(),
ctx.accounts.payer.to_account_info(),
ctx.accounts.system_program.to_account_info(),
)?;
Ok(())
}
All you need to do here is execute the initialize_token_metadata
function we defined earlier, reload the mint account, and then update the mint account's lamports to the minimum balance using the helper function we created earlier.
To test the utility functions and our instruction's accuracy, we can follow the sample program to fetch our mint's extension data and run a few assertions (using Anchor's assert_eq!
macro) to ensure everything is working as expected. If you'd like, update the handler
function to include these assertions:
pub fn handler(ctx: Context<CreateMintAccount>, args: CreateMintAccountArgs) -> Result<()> {
ctx.accounts.initialize_token_metadata(
args.name.clone(),
args.symbol.clone(),
args.uri.clone(),
)?;
ctx.accounts.mint.reload()?;
let mint_data = &mut ctx.accounts.mint.to_account_info();
let metadata = get_mint_extensible_extension_data::<TokenMetadata>(mint_data)?;
assert_eq!(metadata.mint, ctx.accounts.mint.key());
assert_eq!(metadata.name, args.name);
assert_eq!(metadata.symbol, args.symbol);
assert_eq!(metadata.uri, args.uri);
let metadata_pointer = get_mint_extension_data::<MetadataPointer>(mint_data)?;
let mint_key: Option<Pubkey> = Some(ctx.accounts.mint.key());
let authority_key: Option<Pubkey> = Some(ctx.accounts.authority.key());
assert_eq!(
metadata_pointer.metadata_address,
OptionalNonZeroPubkey::try_from(mint_key)?
);
assert_eq!(
metadata_pointer.authority,
OptionalNonZeroPubkey::try_from(authority_key)?
);
let permanent_delegate = get_mint_extension_data::<PermanentDelegate>(mint_data)?;
assert_eq!(
permanent_delegate.delegate,
OptionalNonZeroPubkey::try_from(authority_key)?
);
let close_authority = get_mint_extension_data::<MintCloseAuthority>(mint_data)?;
assert_eq!(
close_authority.close_authority,
OptionalNonZeroPubkey::try_from(authority_key)?
);
let transfer_hook = get_mint_extension_data::<TransferHook>(mint_data)?;
let program_id: Option<Pubkey> = Some(ctx.program_id.key());
assert_eq!(
transfer_hook.authority,
OptionalNonZeroPubkey::try_from(authority_key)?
);
assert_eq!(
transfer_hook.program_id,
OptionalNonZeroPubkey::try_from(program_id)?
);
let group_member_pointer = get_mint_extension_data::<GroupMemberPointer>(mint_data)?;
assert_eq!(
group_member_pointer.authority,
OptionalNonZeroPubkey::try_from(authority_key)?
);
assert_eq!(
group_member_pointer.member_address,
OptionalNonZeroPubkey::try_from(mint_key)?
);
update_account_lamports_to_minimum_balance(
ctx.accounts.mint.to_account_info(),
ctx.accounts.payer.to_account_info(),
ctx.accounts.system_program.to_account_info(),
)?;
Ok(())
}
On the surface, it looks like there is a lot here, but this is just a series of assertions to ensure that the mint's extension data is being set correctly. It can be helpful to use this as a reference to see how you can fetch extension data from your dynamically-sized extensions!
Create Check Mint Extensions Constraints Instruction
Finally, we just need to define the CheckMintExtensionConstraints
instruction context (if you recall in our lib.rs
we already defined the instruction logic, Ok(())
). Add the following code to the instructions.rs
file:
#[derive(Accounts)]
#[instruction()]
pub struct CheckMintExtensionConstraints<'info> {
#[account(mut)]
/// CHECK: can be any account
pub authority: Signer<'info>,
#[account(
extensions::metadata_pointer::authority = authority,
extensions::metadata_pointer::metadata_address = mint,
extensions::group_member_pointer::authority = authority,
extensions::group_member_pointer::member_address = mint,
extensions::transfer_hook::authority = authority,
extensions::transfer_hook::program_id = crate::ID,
extensions::close_authority::authority = authority,
extensions::permanent_delegate::delegate = authority,
)]
pub mint: Box<InterfaceAccount<'info, Mint>>,
}
The Anchor constraint tooling will ensure that the mint account passed in our instruction has the correct extensions set. If you try to run this instruction without the correct extensions set, you will get a constraint error. Let's test it out!
Test the Program
Open the Anchor-generated test, /token-extensions/tests/token-extensions.ts
, and replace the contents with the following code:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { PublicKey, Keypair } from "@solana/web3.js";
import { TokenExtensions } from "../target/types/token_extensions";
import { ASSOCIATED_PROGRAM_ID } from "@coral-xyz/anchor/dist/cjs/utils/token";
import { assert, expect } from "chai";
const TOKEN_2022_PROGRAM_ID = new anchor.web3.PublicKey(
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
);
export function associatedAddress({
mint,
owner,
}: {
mint: PublicKey;
owner: PublicKey;
}): PublicKey {
return PublicKey.findProgramAddressSync(
[owner.toBuffer(), TOKEN_2022_PROGRAM_ID.toBuffer(), mint.toBuffer()],
ASSOCIATED_PROGRAM_ID
)[0];
}
describe("token extensions", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.TokenExtensions as Program<TokenExtensions>;
const payer = Keypair.generate();
it("airdrop payer", async () => {
const tx = await provider.connection.requestAirdrop(payer.publicKey, 10000000000);
let confirmed = false;
while (!confirmed) {
await new Promise(resolve => setTimeout(resolve, 1000));
confirmed = await provider.connection.getSignatureStatuses([tx]);
if (!confirmed) continue;
if (confirmed.value[0].err) {
throw new Error(confirmed.value[0].err.toString());
}
if (confirmed.value[0].confirmationStatus === 'confirmed') {
confirmed = true;
break;
}
}
});
let mint = new Keypair();
it("Create mint account test passes", async () => {
const [extraMetasAccount] = PublicKey.findProgramAddressSync(
[
anchor.utils.bytes.utf8.encode("extra-account-metas"),
mint.publicKey.toBuffer(),
],
program.programId
);
await program.methods
.createMintAccount({
name: "quick token",
symbol: "QT",
uri: "https://my-token-data.com/metadata.json",
})
.accountsStrict({
payer: payer.publicKey,
authority: payer.publicKey,
receiver: payer.publicKey,
mint: mint.publicKey,
mintTokenAccount: associatedAddress({
mint: mint.publicKey,
owner: payer.publicKey,
}),
extraMetasAccount: extraMetasAccount,
systemProgram: anchor.web3.SystemProgram.programId,
associatedTokenProgram: ASSOCIATED_PROGRAM_ID,
tokenProgram: TOKEN_2022_PROGRAM_ID,
})
.signers([mint, payer])
.rpc();
});
it("mint extension constraints test passes", async () => {
try {
const tx = await program.methods
.checkMintExtensionsConstraints()
.accountsStrict({
authority: payer.publicKey,
mint: mint.publicKey,
})
.signers([payer])
.rpc();
assert.ok(tx, "transaction should be processed without error");
} catch (e) {
assert.fail('should not throw error');
}
});
it("mint extension constraints fails with invalid authority", async () => {
const wrongAuth = Keypair.generate();
try {
const x = await program.methods
.checkMintExtensionsConstraints()
.accountsStrict({
authority: wrongAuth.publicKey,
mint: mint.publicKey,
})
.signers([payer, wrongAuth])
.rpc();
assert.fail('should have thrown an error');
} catch (e) {
expect(e, 'should throw error');
}
})
});
Our simple testing environment includes four tests:
airdrop payer
- This test will airdrop 10 SOL to the payer account.Create mint account test passes
- This test will create a new mint account and associated token account and then initialize the metadata for the mint.mint extension constraints test passes
- This test will check the mint extension constraints for the mint account. This transaction should be succesful.mint extension constraints fails with invalid authority
- This test will check the mint extension constraints for the mint account with the wrong authority. For the test to pass, the transaction should fail.
Because we have not compiled our program and created an IDL, you will probably see some type errors in your program. This will be corrected when we run our testing suite.
Run the tests by executing the following command in your terminal:
anchor test
The program should build after a few minutes (on the first run) and then run the tests. If everything is set up correctly, you should see the following output:
token extensions
✔ airdrop payer (176ms)
✔ Create mint account test passes (483ms)
✔ mint extension constraints test passes (470ms)
✔ mint extension constraints fails with invalid authority
Great job!
Wrap up
You have successfully created a new Anchor program that creates a mint account with Token Extensions and validates the mint extension constraints. This program can be a great reference point as you build out your programs with Token Extensions. Note: not all extensions are included in Anchor just yet, so keep an eye out for updates to the Token 2022 and Anchor programs that will include more extensions.
If you're stuck, have questions, or just want to talk about what you're building, drop us a line on Discord or Twitter!
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.