Zero-to-Hero with Solana Token 2022 — Transfer Hook 🪝

Johnny Tordgeman
11 min readJun 24, 2024

--

Photo by Fabian Blank on Unsplash

The Token 2022 program introduced several exciting extensions that enhance the functionality of mints and token accounts. Among these features, my personal favorite is the Transfer Hook🪝.

Imagination Time

Let’s put our imagination cap on for a minute and imagine this lovely scenario: you are an NFT project owner. You issue tokens to your holders for staking their NFTs with you. You treat your project as a “closed-gate community”, meaning only owners of your NFT can hold your tokens. Before there were transfer hooks you had to build your own program to handle transfers which will enforce this behavior. But with transfer hooks it is pretty straightforward!All you need to do is build a transfer hook program that will verify that the destination address of a transfer is indeed one of the whitelisted addresses, and if not, stop the transaction. Another example? Say you want to have users pay an additional fee whenever they make a token transfer… No problem! This can also be achieved by building a hook that will handle the transfer!

Now, these examples may seem silly in the context of an NFT project, but just imagine the bigger picture where such a hook can help comply with regulatory requirements, such as enforcing a holding period or set a maximum ownership limits on a token.

Transfer Hook — But What Is It Really?

As the name suggests, the Transfer Hook extension is closely tied to token transfers. Whenever a mint is configured with a transfer hook an instruction is automatically triggered each time a Transfer Instruction is executed on that mint. If this concept seems familiar, it’s because it is — this flow closely resembles how webhooks work in web2.

The transfer hook instruction has access to a single variable, the transfer amount, however we can provide an additional account (extra-account-meta-list) which will include other custom accounts the instruction can use. We will discuss this account in more detail soon.

While the transfer hook does have access to the initial transfer accounts, it’s important to know that they are passed as read-only accounts, meaning the sender signer privileges are not extended to the transfer hook program.

Lastly, when writing a transfer hook program using Anchor, a fallback instruction is needed to manually match the native program instruction discriminator and invoke the transfer hook instruction. This is due to the fact that the Token 2022 program is a native program, thus we need to “bridge” its native interface with Anchor.

Time To Play

Okay, enough talking — let’s build something! We’re going to build a ‘Whale Alert’ transfer hook 🐋! For each transfer of our token, the transfer hook instruction will compare the transfer amount against a predefined value (e.g., 10,000 tokens). If it’s equal to or greater than that value, we’ll update an account with the latest whale’s details and emit an event for a client to act upon.

⚒️ You will need the Solana CLI tools and Anchor installed to follow along. If you don't have them installed, check out the Anchor docs for instructions.

Step 1: The Transfer Hook Program

Let’s design our transfer hook program. The first thing our transfer hook program we will need is an instruction that can be called once a transfer is done. We are going to build our program with Anchor, however, the Token 2022 program is a native Solana program — thus we need another instruction: an instruction to match an instruction discriminator to a transfer hook execute interface, and invoke the transfer_hook instruction when matched. So that’s two instructions so far.

When our hook is called, it compares the transfer amount against a value — but where does this value come from? Sure we can hard code that value in the program — but what happen if tomorrow we decide we want to increase/decrease the value? we need to deploy the program again. A better way of handling this would be to save the comparison value in an account. To pass additional accounts to the transfer hook we use the extra_account_meta_list account. This is a PDA account which store accounts the transfer_hook can later use when invoked. The extra_account_meta_list account will always use the hard-coded string extra-account-metas and the token’s mint address as its seeds.

Now you may be asking yourself how do we create and initialize this extra_account_meta_list account? This is where the third and last instruction of our program comes in — the initialize_extra_account_meta_list instruction.

With the high-level design of our program done, let’s get down to business and actually write the transfer hook program 🎉.

  1. Create a new Anchor project and open it in your favorite IDE:
anchor init transfer-hook-whale

2. Install the anchor-spl, spl-transfer-hook-interface, and spl_tlv_account_resolution crates. These crates contain helper functions and interfaces that will make our life easier when working with SPL tokens and extension instructions.

cd programs/transfer-hook-whale
cargo add anchor-spl spl-transfer-hook-interface spl_tlv_account_resolution

3. Starting with Anchor 0.30 we need to tell Anchor to generate the type definitions for the crates we use, so we need to tell it to generate type definitions for the anchor-spl crate. Open Cargo.toml inside the programs/transfer-hook-whale folder and update idl-build as follows:

[features]
...
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]

4. Open lib.rs and add the following use statements:

use anchor_lang::system_program::{create_account, CreateAccount};
use anchor_spl::{
associated_token::AssociatedToken,
token_interface::{Mint, TokenInterface},
};
use spl_transfer_hook_interface::instruction::TransferHookInstruction;

use spl_tlv_account_resolution::{
account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList,
};
use spl_transfer_hook_interface::instruction::ExecuteInstruction;

A transfer hook program will typically consist of three instructions:

  • initialize_extra_account_meta — An instruction that creates an account (extra_account_meta_list) which holds a list of extra accounts the transfer hook can use.
    In our case this account will hold details for the latest “whale” address and amount.
  • transfer_hook — The hook itself. This is the actual instruction that gets called whenever a token transfer occurs.
  • fallback — The Token 2022 program is a native program, but we are using Anchor to build our program, therefore we need to add a fallback instruction that will match an instruction discriminator to a transfer hook execute interface, and invokes our transfer_hook instruction when matched.

Implementing the initialize_extra_account_meta Instruction

  1. At the bottom of your lib.rs, add the following account. This account will hold the details of the latest “Whale” transfer:
#[account]
pub struct WhaleAccount {
pub whale_address: Pubkey,
pub transfer_amount: u64
}

2. Next, we define all the accounts the initialize_extra_account_meta instruction will need for its execution. Add the following snippet above LatestWhaleAccount:

#[derive(Accounts)]
pub struct InitializeExtraAccountMeta<'info> {
#[account(mut)]
pub payer: Signer<'info>,
/// CHECK: ExtraAccountMetaList Account, must use these exact seeds
#[account(mut, seeds=[b"extra-account-metas", mint.key().as_ref()], bump)]
pub extra_account_meta_list: AccountInfo<'info>,
pub mint: InterfaceAccount<'info, Mint>,
#[account(init, seeds=[b"whale_account"], bump, payer=payer, space=8+32+8)]
pub latest_whale_account: Account<'info, WhaleAccount>,
pub token_program: Interface<'info, TokenInterface>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
}

🧑‍🏫 Pointers Time:

  • The account that will hold the extra accounts (extra_account_meta_list) must have a very specific seed for its PDA: the byte string extra-account-metas and the public key of the token’s mint account.
  • The account that will hold the whale details (latest_whale_account) will have a simple seed for its PDA: the bytes of the string whale_account. This means that all the tokens in our mint will share the same account.
  • We must supply the token program, associated token program and system program to the instruction as it will have to use all of them during its run.

3. Next, let’s add the initialize_extra_account_meta instruction. Replace the default initialize instruction with the following:

pub fn initialize_extra_account(ctx: Context<InitializeExtraAccountMeta>) -> Result<()> {
// This is the vector of the extra accounts we will need. In our case
// there is only one account - the whale details account.
let account_metas = vec![ExtraAccountMeta::new_with_seeds(
&[Seed::Literal {
bytes: "whale_account".as_bytes().to_vec(),
}],
false,
true,
)?];

// Calculate the account size and the rent
let account_size = ExtraAccountMetaList::size_of(account_metas.len())? as u64;
let lamports = Rent::get()?.minimum_balance(account_size as usize);

// Get the mint account public key from the context.
let mint = ctx.accounts.mint.key();

// The seeds for the ExtraAccountMetaList PDA.
let signer_seeds: &[&[&[u8]]] = &[&[
b"extra-account-metas",
&mint.as_ref(),
&[ctx.bumps.extra_account_meta_list],
]];

// Create the ExtraAccountMetaList account
create_account(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
CreateAccount {
from: ctx.accounts.payer.to_account_info(),
to: ctx.accounts.extra_account_meta_list.to_account_info(),
},
)
.with_signer(signer_seeds),
lamports,
account_size,
ctx.program_id,
)?;

// Initialize the ExtraAccountMetaList account with the extra accounts
ExtraAccountMetaList::init::<ExecuteInstruction>(
&mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,
&account_metas,
)?;

Ok(())
}

There’s a lot going on here so let’s try and eat it one bite at a time:

  1. First we declare a vector (account_metas) of ExtraAccountMeta accounts that specify the different extra accounts we will use. These can be specified from seeds, a public key etc. In our case there is only one extra account we need — the whale details account.
  2. We calculate the account size and based on that the amount of lamports needed for rent.
  3. We specify the seeds needed to sign as the extra_account_meta_list PDA.
  4. We call the create_account CPI to actually create the extra_account_meta_list account, providing it with all the needed data (payer, signer seeds, account size etc).
  5. Finally, we initialize the newly created account with the vector of extra accounts we declared in step 1.

Implementing the transfer_hook Instruction

We reached the crown jewel of our program — the transfer_hook instruction! This is where the real action takes place, as this instruction will be called whenever a transaction is made.

  1. We start by adding the incoming accounts for the instruction. Add the following in your lib.rs below InitializeExtraAccountMeta:
#[derive(Accounts)]
pub struct TransferHook<'info> {
#[account(token::mint = mint, token::authority = owner)]
pub source_token: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
#[account(token::mint = mint)]
pub destination_token: InterfaceAccount<'info, TokenAccount>,
/// CHECK: source token account owner,
/// can be SystemAccount or PDA owned by another program
pub owner: UncheckedAccount<'info>,
/// CHECK: ExtraAccountMetaList Account,
#[account(seeds = [b"extra-account-metas", mint.key().as_ref()],bump)]
pub extra_account_meta_list: UncheckedAccount<'info>,
#[account(mut, seeds=[b"whale_account"], bump)]
pub latest_whale_account: Account<'info, WhaleAccount>,
}

🧑‍🏫 Pointers Time:

  • The order of the accounts specified here IS IMPORTANT:
    - The first four accounts are accounts that are required for token transfer (source, mint, destination and owner — in this order),
    - The fifth account is the address of the ExtraAccountMetaList account.
    - The remaining accounts are the extra accounts required by the ExtraAccountMetaList account.
  • Notice the constraints we are passing here — they are there to help us make sure the accounts passed are really the accounts we expect (i.e the right mint account, the right owner etc.)

2. We can now move forward to the actual instruction. Add the following snippet right below the initialize_extra_account instruction:

pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
msg!(&format!("Transfer hook fired for an amount of {}", amount));

if amount >= 1000 * (u64::pow(10, ctx.accounts.mint.decimals as u32)) { {
// we have a whale!
ctx.accounts.latest_whale_account.whale_address = ctx.accounts.owner.key();
ctx.accounts.latest_whale_account.transfer_amount = amount;

emit!(WhaleTransferEvent {
whale_address: ctx.accounts.owner.key(),
transfer_amount: amount
});
}

Ok(())
}

🦁 Nothing too wild here:

  • We check if the amount transferred is larger than or equal to 1000 tokens (thats the arbitrary amount i chose, feel free to choose another amount).
  • If the amount is indeed 1000 or more we update the whale details account with the new address and amount
  • Finally we emit an event called WhaleTransferEvent for clients (i.e a Discord bot, a web app etc) to listen to.

3. At the bottom of lib.rs add the actual event declaration:

#[event]
pub struct WhaleTransferEvent {
pub whale_address: Pubkey,
pub transfer_amount: u64,
}

Implementing the fallback Instruction

Last but not least, the fallback instruction. Add the following snippet after the transfer_hook instruction:

pub fn fallback<'info>(
program_id: &Pubkey,
accounts: &'info [AccountInfo<'info>],
data: &[u8],
) -> Result<()> {
let instruction = TransferHookInstruction::unpack(data)?;

// match instruction discriminator to transfer hook interface execute instruction
// token2022 program CPIs this instruction on token transfer
match instruction {
TransferHookInstruction::Execute { amount } => {
let amount_bytes = amount.to_le_bytes();

// invoke custom transfer hook instruction on our program
__private::__global::transfer_hook(program_id, accounts, &amount_bytes)
}
_ => return Err(ProgramError::InvalidInstructionData.into()),
}
}

The fallback instruction is taken from Solana’s hello-world transfer hook example .

While it may look complicated this instruction is rather straight forward:

  1. Unpack the instruction data and cast it as a TransferHookInstruction.
  2. Match the TransferHookInstruction enum.
  3. If its the Execute instruction, get the amount of the transfer and call the transfer_hook instruction.
  4. If its not the Execute instruction — return an error of invalid instruction data.

Go ahead and deploy the program to Devnet 🎉

Step 2: The Mint

Ok so we got our program deployed, now we can shift our focus to create a token mint that will actually use it.

  1. Create a mint using the following command:
 spl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb create-token --transfer-hook <your transfer hook program id>

🧑‍🏫 Pointers Time:

  • Notice the program id in the command? That’s the address of the Token 2022 program. We have to specify it for the mint to actually use the new standard.
  • Registering the transfer hook for the mint is as easy as adding the --transfer-hook <address>subcommand to the spl-token command.

2. Create the token account for your configured wallet:

spl-token create-account <the token address from previous step>

3. Mint some tokens to your wallet:

 spl-token mint <the token address from previous step> 3000

Step 3: Initializing the ExtraAccountMeta Account

If you try and transfer tokens right (with the spl-token transfer command for example) you’ll end up with an AccountNotFound error. This is because we never initialized our old friend ExtraAccountMetaList

We created an instruction on our program to initialize the account, initialize_extra_account, so now would be the best time to call it.

I created this little TypeScript code to call it, but feel free to use whatever method you like:

import { readFile } from "fs/promises";
import * as anchor from "@coral-xyz/anchor";
// These two files are copied from the built Anchor program Target/idl and Target/types folders.
import { TransferHookWhale } from "./program/transfer_hook_whale";
import idl from './program/transfer_hook_whale.json';

import { TOKEN_2022_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from "@solana/spl-token";
import "dotenv/config";

const kpFile = ".<your keypair file>";
const mint = new anchor.web3.PublicKey("<your mint public address>")

const main = async () => {

if (!process.env.SOLANA_RPC) {
console.log("Missing required env variables");
return;
}

console.log("💰 Reading wallet...");
const keyFile = await readFile(kpFile);
const keypair: anchor.web3.Keypair = anchor.web3.Keypair.fromSecretKey(new Uint8Array(JSON.parse(keyFile.toString())));
const wallet = new anchor.Wallet(keypair);

console.log("☕️ Setting provider and program...");
const connection = new anchor.web3.Connection(process.env.SOLANA_RPC);
const provider = new anchor.AnchorProvider(connection, wallet, {});
anchor.setProvider(provider);
const program = new anchor.Program<TransferHookWhale>(idl as TransferHookWhale, provider);

console.log("🪝 Initializing transfer hook accounts");
const [extraAccountMetaListPDA] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("extra-account-metas"), mint.toBuffer()],
program.programId
);

const [whalePDA] = anchor.web3.PublicKey.findProgramAddressSync([Buffer.from("whale_account")], program.programId);

const initializeExtraAccountMetaListInstruction = await program.methods
.initializeExtraAccount()
.accounts({
mint,
extraAccountMetaList: extraAccountMetaListPDA,
latestWhaleAccount: whalePDA,
systemProgram: anchor.web3.SystemProgram.programId,
tokenProgram: TOKEN_2022_PROGRAM_ID,
associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
})
.instruction();

const transaction = new anchor.web3.Transaction().add(initializeExtraAccountMetaListInstruction);

const tx = await anchor.web3.sendAndConfirmTransaction(connection, transaction, [wallet.payer], {
commitment: "confirmed",
});

console.log("Transaction Signature:", tx);
}

main().then(() => {
console.log("done!");
process.exit(0);
}).catch((e) => {
console.log("Error: ", e);
process.exit(1);
});

💈 Full project is on GitHub

Step 4: Let the Transfers Begin!

And that’s it folks! With the transfer hook program deployed, a mint configured to use it, and the ExtraAccountMetaList account initialized we can FINALLY take our hook for a ride:

Transfer hook in action!

Closing Notes

  • You can find the full source of the whale transfer hook program here.
  • You can find the source of the sample listener here.
  • Comments are more than welcome :)

Huge thanks to Sara Lumelsky for helping me with grammar and stuff 😅

--

--

Johnny Tordgeman

Head Dev @DropoutUniv | Principal @SecureWithHUMAN| Loves to talk about Rust 🦀 / TypeScript / JavaScript / WebAssembly | Web3 is AWESOME