-
In this demo, we build a more advanced
transfer_hook
program that requires the sender to pay a wSOL fee for every token transfer. -
The wSOL transfers are executed using a delegate PDA from the transfer hook program (as the signature from the initial sender of the token transfer instruction is not accessible):
pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
msg!("Transfer WSOL using delegate PDA");
let signer_seeds: &[&[&[u8]]] = &[&[b"delegate", &[ctx.bumps.delegate]]];
transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.sender_wsol_token_account.to_account_info(),
mint: ctx.accounts.wsol_mint.to_account_info(),
to: ctx.accounts.delegate_wsol_token_account.to_account_info(),
authority: ctx.accounts.delegate.to_account_info(),
},
)
.with_signer(signer_seeds),
amount,
ctx.accounts.wsol_mint.decimals,
)?;
Ok(())
}
- Note that we organize the directories and files as recommended by Neodyme's Secure Scaffold:
.
├── Anchor.toml
├── Cargo.toml
├── README.md
├── package.json
├── programs
│ └── transfer-hooks-with-w-soi
│ ├── Cargo.toml
│ ├── Xargo.toml
│ └── src
│ ├── errors.rs
│ ├── instructions
│ │ ├── metalist.rs
│ │ ├── mod.rs
│ │ └── transfer_hook.rs
│ ├── lib.rs
│ └── state
│ ├── global.rs
│ └── mod.rs
├── tests
│ └── transfer-hooks-with-w-soi.ts
└── tsconfig.json
-
We covered the basics in the previous demos, so now let's use this example to go over the flow of a transfer hook.
-
First, we import the required interfaces for this program,
spl_tlv_account_resolution
andspl_transfer_hook_interface
:
use anchor_lang::{
prelude::*,
system_program::{create_account, CreateAccount},
};
use anchor_spl::{
associated_token::AssociatedToken,
token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked},
};
use spl_tlv_account_resolution::{
account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList,
};
use spl_transfer_hook_interface::instruction::{ExecuteInstruction, TransferHookInstruction};
declare_id!("3VTHXbzY92FgZR7TK58pbEoFnrzrAWLdwj65JiXB2MV1");
- We start the
#[program]
module:
#[program]
pub mod transfer_hooks_with_w_soi {
use super::*;
- We create an account that stores a list of extra accounts required by the
transfer_hook()
instruction, where:- indices 0-3 are the accounts required for token transfer (source, mint, destination, owner)
- index 4 is the address of the
ExtraAccountMetaList
account - index 5 is the wrapped SOL mint account
- index 6 is the token program account
- index 7 is the associated token program
- index 8 is the delegate PDA
- index 9 is the delegate wrapped SOL token account
- index 10 is the sender wrapped SOL token account
pub fn initialize_extra_account_meta_list(
ctx: Context<InitializeExtraAccountMetaList>,
) -> Result<()> {
let account_metas = vec![
ExtraAccountMeta::new_with_pubkey(
&ctx.accounts.wsol_mint.key(),
false,
false)?,
ExtraAccountMeta::new_with_pubkey(
&ctx.accounts.token_program.key(),
false,
false)?,
ExtraAccountMeta::new_with_pubkey(
&ctx.accounts.associated_token_program.key(),
false,
false,
)?,
ExtraAccountMeta::new_with_seeds(
&[Seed::Literal {
bytes: "delegate".as_bytes().to_vec(),
}],
false,
true,
)?,
ExtraAccountMeta::new_external_pda_with_seeds(
7, // associated token program index
&[
Seed::AccountKey { index: 8 }, // owner index (delegate PDA)
Seed::AccountKey { index: 6 }, // token program index
Seed::AccountKey { index: 5 }, // wsol mint index
],
false,
true,
)?,
ExtraAccountMeta::new_external_pda_with_seeds(
7, // associated token program index
&[
Seed::AccountKey { index: 3 }, // owner index
Seed::AccountKey { index: 6 }, // token program index
Seed::AccountKey { index: 5 }, // wsol mint index
],
false, // is_signer
true, // is_writable
)?
];
- Let's create the PDA:
let account_size = ExtraAccountMetaList::size_of(account_metas.len())? as u64;
let lamports = Rent::get()?.minimum_balance(account_size as usize);
let mint = ctx.accounts.mint.key();
let signer_seeds: &[&[&[u8]]] = &[&[
b"extra-account-metas",
&mint.as_ref(),
&[ctx.bumps.extra_account_meta_list],
]];
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,
)?;
- Let's write all the signers in the meta list:
ExtraAccountMetaList::init::<ExecuteInstruction>(
&mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,
&account_metas,
)?;
Ok(())
}
- The
transfer_hook()
instruction is invoked via CPI on every token transfer to perform a wrapped SOL token transfer using a delegate PDA:
pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
let signer_seeds: &[&[&[u8]]] = &[&[b"delegate", &[ctx.bumps.delegate]]];
msg!("Transfer WSOL using delegate PDA");
transfer_checked(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
TransferChecked {
from: ctx.accounts.sender_wsol_token_account.to_account_info(),
mint: ctx.accounts.wsol_mint.to_account_info(),
to: ctx.accounts.delegate_wsol_token_account.to_account_info(),
authority: ctx.accounts.delegate.to_account_info(),
},
)
.with_signer(signer_seeds),
amount,
ctx.accounts.wsol_mint.decimals,
)?;
Ok(())
}
- Whenever the token is transferred, the
TransferHookInstruction::Execute
fromfallback()
is executed, which takes the bytes out of the data withto_le_bytes()
to calltransfer_hook()
above:
pub fn fallback<'info>(
program_id: &Pubkey,
accounts: &'info [AccountInfo<'info>],
data: &[u8],
) -> Result<()> {
let instruction = TransferHookInstruction::unpack(data)?;
match instruction {
TransferHookInstruction::Execute { amount } => {
let amount_bytes = amount.to_le_bytes();
__private::__global::transfer_hook(program_id, accounts, &amount_bytes)
}
_ => return Err(ProgramError::InvalidInstructionData.into()),
}
}
}
- Let's look at the accounts:
#[derive(Accounts)]
pub struct InitializeExtraAccountMetaList<'info> {
#[account(mut)]
payer: Signer<'info>,
#[account(
mut,
seeds = [b"extra-account-metas", mint.key().as_ref()],
bump
)]
pub extra_account_meta_list: AccountInfo<'info>,
pub mint: InterfaceAccount<'info, Mint>,
pub wsol_mint: InterfaceAccount<'info, Mint>,
pub token_program: Interface<'info, TokenInterface>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
}
#[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
pub owner: UncheckedAccount<'info>,
/// CHECK
#[account(
seeds = [b"extra-account-metas", mint.key().as_ref()],
bump
)]
pub extra_account_meta_list: UncheckedAccount<'info>,
pub wsol_mint: InterfaceAccount<'info, Mint>,
pub token_program: Interface<'info, TokenInterface>,
pub associated_token_program: Program<'info, AssociatedToken>,
#[account(
mut,
seeds = [b"delegate"],
bump
)]
pub delegate: SystemAccount<'info>,
#[account(
mut,
token::mint = wsol_mint,
token::authority = delegate,
)]
pub delegate_wsol_token_account: InterfaceAccount<'info, TokenAccount>,
#[account(
mut,
token::mint = wsol_mint,
token::authority = owner,
)]
pub sender_wsol_token_account: InterfaceAccount<'info, TokenAccount>,
}
- Now let's look at
test/transfer-hooks-with-w-soi.ts
. We start by importing dependencies and retrieving the IDL file:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { TransferHooksWithWSoi } from "../target/types/transfer_hooks_with_w_soi";
import {
PublicKey,
SystemProgram,
Transaction,
sendAndConfirmTransaction,
Keypair,
} from "@solana/web3.js";
import {
ExtensionType,
TOKEN_2022_PROGRAM_ID,
getMintLen,
createInitializeMintInstruction,
createInitializeTransferHookInstruction,
ASSOCIATED_TOKEN_PROGRAM_ID,
createAssociatedTokenAccountInstruction,
createMintToInstruction,
getAssociatedTokenAddressSync,
createApproveInstruction,
createSyncNativeInstruction,
NATIVE_MINT,
TOKEN_PROGRAM_ID,
getAccount,
getOrCreateAssociatedTokenAccount,
createTransferCheckedWithTransferHookInstruction,
} from "@solana/spl-token";
import assert from "assert";
- We create Anchor's
Provider
, get the program from the IDL, the wallet provider, and the connection:
describe("transfer_hooks_with_w_soi", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.TransferHooksWithWSoi as Program<TransferHooksWithWSoi>;
const wallet = provider.wallet as anchor.Wallet;
const connection = provider.connection;
- We generate keypair to use as an address for the
transfer-hook()
enabled mint:
const mint = new Keypair();
const decimals = 9;
- Create the source token account (from the sender):
const sourceTokenAccount = getAssociatedTokenAddressSync(
mint.publicKey,
wallet.publicKey,
false,
TOKEN_2022_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID
);
- Create the recipient (random keypair) and the recipient's token account:
const recipient = Keypair.generate();
const destinationTokenAccount = getAssociatedTokenAddressSync(
mint.publicKey,
recipient.publicKey,
false,
TOKEN_2022_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID
);
- Get the meta accounts need for the Transfer Hook:
const [extraAccountMetaListPDA] = PublicKey.findProgramAddressSync(
[Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()],
program.programId
);
// PDA delegate to transfer wSOL tokens from sender
const [delegatePDA] = PublicKey.findProgramAddressSync(
[Buffer.from("delegate")],
program.programId
);
// Sender wSOL token account address
const senderWSolTokenAccount = getAssociatedTokenAddressSync(
NATIVE_MINT, // mint
wallet.publicKey // owner
);
// Delegate PDA wSOL token account address, to receive wSOL tokens from sender
const delegateWSolTokenAccount = getAssociatedTokenAddressSync(
NATIVE_MINT, // mint
delegatePDA, // owner
true // allowOwnerOffCurve
);
// Create the two wSOL token accounts as part of setup
before(async () => {
// wSOL Token Account for sender
await getOrCreateAssociatedTokenAccount(
connection,
wallet.payer,
NATIVE_MINT,
wallet.publicKey
);
// wSOL Token Account for delegate PDA
await getOrCreateAssociatedTokenAccount(
connection,
wallet.payer,
NATIVE_MINT,
delegatePDA,
true
);
});
- Create the mint account, adding some extra space through
extensions
:
it("Create Mint Account with Transfer Hook Extension", async () => {
const extensions = [ExtensionType.TransferHook];
const mintLen = getMintLen(extensions);
const lamports =
await provider.connection.getMinimumBalanceForRentExemption(mintLen);
- Create an account, initialize the Transfer Hook instruction, initialize the Mint, and send the transaction:
const transaction = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: mint.publicKey,
space: mintLen,
lamports: lamports,
programId: TOKEN_2022_PROGRAM_ID,
}),
createInitializeTransferHookInstruction(
mint.publicKey,
wallet.publicKey,
program.programId, // Transfer Hook Program ID
TOKEN_2022_PROGRAM_ID
),
createInitializeMintInstruction(
mint.publicKey,
decimals,
wallet.publicKey,
null,
TOKEN_2022_PROGRAM_ID
)
);
const txSig = await sendAndConfirmTransaction(
provider.connection,
transaction,
[wallet.payer, mint]
);
console.log(`Transaction Signature: ${txSig}`);
});
- Create two associated token accounts (one for the wallet and one for the destination) for the transfer-hook enabled mint, and send the tranasction:
// Fund the sender token account with 100 tokens
it("Create Token Accounts and Mint Tokens", async () => {
const amount = 100 * 10 ** decimals;
const transaction = new Transaction().add(
createAssociatedTokenAccountInstruction(
wallet.publicKey,
sourceTokenAccount,
wallet.publicKey,
mint.publicKey,
TOKEN_2022_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID
),
createAssociatedTokenAccountInstruction(
wallet.publicKey,
destinationTokenAccount,
recipient.publicKey,
mint.publicKey,
TOKEN_2022_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID
),
createMintToInstruction(
mint.publicKey,
sourceTokenAccount,
wallet.publicKey,
amount,
[],
TOKEN_2022_PROGRAM_ID
)
);
const txSig = await sendAndConfirmTransaction(
connection,
transaction,
[wallet.payer],
{ skipPreflight: true }
);
console.log(`Transaction Signature: ${txSig}`);
});
- The third account creates an extra account meta to store extra accounts required by the transfer hook instruction. Note that this is a PDA derived from our program:
it("Create ExtraAccountMetaList Account", async () => {
const initializeExtraAccountMetaListInstruction = await program.methods
.initializeExtraAccountMetaList()
.accounts({
payer: wallet.publicKey,
extraAccountMetaList: extraAccountMetaListPDA,
mint: mint.publicKey,
wsolMint: NATIVE_MINT,
tokenProgram: TOKEN_PROGRAM_ID,
associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
})
.instruction();
const transaction = new Transaction().add(
initializeExtraAccountMetaListInstruction
);
const txSig = await sendAndConfirmTransaction(
provider.connection,
transaction,
[wallet.payer],
{ skipPreflight: true, commitment : "confirmed"}
);
console.log("Transaction Signature:", txSig);
});
- Finally, we now transfer the first time the token, where the most important part is
createTransferCheckedWithTransferHookInstruction
, a helper account that gets all these accounts:
it("Transfer Hook with Extra Account Meta", async () => {
const amount = 1 * 10 ** decimals;
const bigIntAmount = BigInt(amount);
// Instruction for sender to fund their WSol token account
const solTransferInstruction = SystemProgram.transfer({
fromPubkey: wallet.publicKey,
toPubkey: senderWSolTokenAccount,
lamports: amount,
});
// Approve delegate PDA to transfer WSol tokens from sender wSOL token account
const approveInstruction = createApproveInstruction(
senderWSolTokenAccount,
delegatePDA,
wallet.publicKey,
amount,
[],
TOKEN_PROGRAM_ID
);
// Sync sender wSOL token account
const syncWrappedSolInstruction = createSyncNativeInstruction(
senderWSolTokenAccount
);
// Standard token transfer instruction
const transferInstruction = await createTransferCheckedWithTransferHookInstruction(
connection,
sourceTokenAccount,
mint.publicKey,
destinationTokenAccount,
wallet.publicKey,
bigIntAmount,
decimals,
[],
"confirmed",
TOKEN_2022_PROGRAM_ID
);
console.log("Pushed keys:", JSON.stringify(transferInstruction.keys));
const transaction = new Transaction().add(
solTransferInstruction,
syncWrappedSolInstruction,
approveInstruction,
transferInstruction
);
const txSig = await sendAndConfirmTransaction(
connection,
transaction,
[wallet.payer],
{ skipPreflight: true }
);
console.log("Transfer Signature:", txSig);
const tokenAccount = await getAccount(connection, delegateWSolTokenAccount);
assert.equal(Number(tokenAccount.amount), amount);
});
});
- Build and run the tests:
anchor build
anchor test --detach
- Find the
programId
: this should be inside ofAnchor.toml
,test/transfer-hooks-with-w-soi.ts
, andprograms/src/lib.rs
(updating theprogramId
after initialization of new Anchor projects is no longer necessary with new Anchor versions).
anchor keys list
- This test results in three transactions. The last one is the extra transfer, which you can see at the Solana Explorer (
localhost
).