Transfer Hook 扩展和 Transfer Hook 接口引入了创建 mint account 的能力,使其在每次代币转账时执行自定义指令逻辑。
这为代币转账解锁了许多全新的使用场景,例如:
- 强制执行 NFT 版税
- 黑名单或白名单可接收代币的钱包
- 对代币转账实施自定义手续费
- 创建自定义代币转账事件
- 追踪代币转账的统计数据
- 以及更多用途
为实现这一目标,开发者需要构建一个实现 Transfer Hook Interface 的程序,并初始化一个启用了 Transfer Hook 扩展的 mint account。
对于每一笔涉及该 mint account 代币的转账,Token Extensions Program 都会通过 Cross Program Invocation(CPI)调用 Transfer Hook 程序上的指令。
当 Token Extensions Program 通过 CPI 调用 Transfer Hook 程序时,初始转账中的所有账户都将转换为只读账户。这意味着发送方的签名权限不会延伸到 Transfer Hook 程序。
此设计决策旨在防止 Transfer Hook 程序被恶意利用。
在本指南中,我们将使用 Anchor 框架创建一个 Transfer Hook 程序,但也可以使用原生程序来实现 Transfer Hook 接口。在此了解更多关于 Anchor 框架的信息: Anchor Framework
Transfer Hook Interface 概览
Transfer Hook Interface 为开发者提供了一种方式,用于实现针对特定 mint account 在每次代币转账时执行的自定义指令逻辑。
Transfer Hook Interface 规定了以下 指令:
Execute:Token Extensions Program 在每次代币转账时调用的指令。InitializeExtraAccountMetaList(可选):创建一个账户,用于存储自定义Execute指令所需的额外账户列表。UpdateExtraAccountMetaList(可选):通过覆盖现有列表来更新额外账户列表。
从技术上讲,并不强制要求使用该接口来实现 InitializeExtraAccountMetaList 指令。该账户可以由 Transfer Hook 程序上的任何指令创建。
但是,该账户的 Program Derived Address(PDA)必须使用以下 seed 派生:
- 硬编码字符串 "extra-account-metas"
- mint account 地址
- Transfer Hook 程序 ID
const [pda] = PublicKey.findProgramAddressSync([Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()],program.programId // transfer hook program ID);
通过将 Execute 指令所需的额外账户存储在预定义的 PDA 中,这些账户可以从客户端自动添加到代币转账指令中。
Hello-world Transfer Hook
此示例是 Transfer Hook 的 hello world 入门程序。这是一个简单的 Transfer Hook,每次代币转账时仅打印一条消息。我们从在 Solana Playground(一个用于构建和部署 Solana 程序的在线工具)中打开示例开始: link
该示例包含一个实现了 Transfer Hook 接口的 Anchor 程序以及一个用于测试该程序的测试文件。
此程序仅包含 3 条指令:
initialize_extra_account_meta_list:创建一个账户,用于存储transfer_hook指令所需的额外账户列表。在 hello world 示例中,我们将其留空。transfer_hook:此指令在每次代币转账时通过 CPI 调用,用于执行包装 SOL 代币转账。fallback:由于我们使用的是 Anchor 而 Token Program 是原生程序,因此需要添加一条 fallback 指令来手动匹配指令鉴别器并调用我们自定义的transfer_hook指令。无需修改此函数。
每次代币被转账时,Token Program 都会调用此 transfer_hook 函数。
pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {msg!("Hello Transfer Hook!");Ok(())}
在此函数中,您现在可以添加额外的逻辑。例如,您可以让转账在转账金额超过 50 时失败,如下所示:
#[error_code]pub enum MyError {#[msg("The amount is too big")]AmountTooBig,}pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {msg!("Hello Transfer Hook!");if amount > 50 {return err!(MyError::AmountTooBig);}Ok(())}
要在 Solana Playground 中运行此示例,请访问以下链接: link
在 Playground 的终端中,运行 build 命令,这将使用新生成的程序 ID 更新 lib.rs 文件中 declare_id 的值。然后运行 deploy 命令将程序部署到 devnet。程序部署完成后,可在终端中使用 test 命令运行测试文件。
这将输出类似如下的结果:
transfer-hook.test.ts:transfer-hookTransaction Signature: kB8Hkn8NEavK7xztEhQZXKSeidgEK81PZNmgSSodZFVyzM9o18GwNi4bDWD9Q3cbmh75Vn1jqyinYH3YdgJfnuJ✔ Create Mint Account with Transfer Hook Extension (539ms)Transaction Signature: Bf9eYieas6jpV8UxS5upuRv2oMebDdHgDstLMw86ptM7cd4qRpaxRyFYmNZC1WZMcDXP68PoGoApUrrrQKeBbJA✔ Create Token Accounts and Mint Tokens (744ms)Transaction Signature: 3oRtCjM6oSdkxQKUyGF3r6hmZGLUpNefihHoGQT5cftRPeQtimvVukLPvb3PSpvLrUsoCWBnz6nSm6ZbPRUhx7UP✔ Create ExtraAccountMetaList Account (728ms)Transfer Signature: WNAWK2o7wWpVCqPz2uoMtHRe1F5B1jfW8v4kezdQYqaXE3nRAPfqUFkFHg31uYmpZCjncZUwo4g9ZuhgMC9cS1i✔ Transfer Hook with Extra Account Meta (1327ms)4 passing (3s)
如果您不想使用 JavaScript 创建代币,也可以在部署程序后,通过 Solana CLI 使用 spl-token 命令:
spl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb create-token --transfer-hook yourTransferHookProgramId
Counter Transfer Hook
下一个示例将展示如何在每次代币转账时递增一个计数器。 link
如果您想在 Transfer Hook 中添加需要额外账户的逻辑,则需要将这些账户添加到 ExtraAccountMetaList 账户中。在本例中,我们需要一个 PDA 来保存代币被转账次数的计数。
这可以通过在 initialize_extra_account_meta_list 指令中添加以下代码来实现:
let account_metas = vec![ExtraAccountMeta::new_with_seeds(&[Seed::Literal {bytes: "counter".as_bytes().to_vec(),}],false, // is_signertrue, // is_writable)?,];
此外,我们还需要在初始化新的 mint account 时创建此账户,并在每次转账代币时传入该账户。
#[derive(Accounts)]pub struct InitializeExtraAccountMetaList<'info> {#[account(mut)]payer: Signer<'info>,/// CHECK: ExtraAccountMetaList Account, must use these 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_if_needed,seeds = [b"counter"],bump,payer = payer,space = 16)]pub counter_account: Account<'info, CounterAccount>,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: source token account owner, can be SystemAccount or PDA owned by another programpub 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"counter"],bump)]pub counter_account: Account<'info, CounterAccount>,}
该账户将持有一个 u64 类型的计数器变量:
#[account]pub struct CounterAccount {counter: u64,}
现在,在我们的 Transfer Hook 函数中,每次被调用时只需将此计数器加一:
pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {ctx.accounts.counter_account.counter.checked_add(1).unwrap();msg!("This token has been transferred {0} times", ctx.accounts.counter_account.counter);Ok(())}
在客户端中,这些额外账户通过辅助函数 createTransferCheckedWithTransferHookInstruction 自动添加:
let transferInstructionWithHelper =await createTransferCheckedWithTransferHookInstruction(connection,sourceTokenAccount,mint.publicKey,destinationTokenAccount,wallet.publicKey,amountBigInt,decimals,[],"confirmed",TOKEN_2022_PROGRAM_ID);
要在 Solana Playground 中运行此示例,请访问以下链接: link
然后在终端中输入 build,这将使用新生成的程序 ID 更新 lib.rs 文件中 declare_id 的值。然后输入 deploy 将程序部署到 devnet。程序部署完成后,可在终端中输入 test 来运行测试文件。
这将输出如下结果。在最后一笔交易中,您将能看到您的代币被转账的次数:
"This token has been transferred 1 times"
Running tests...transfer-hook.test.ts:transfer-hookTransaction Signature: 48r6effAA4B9RVh13eBXdGjmcPKcm6QwnvodX2dT5nNfJyzoS3AejqatKXyqcmpzPdcmpTjgALnd1xx7v17ggptV✔ Create Mint Account with Transfer Hook Extension (545ms)Transaction Signature: nfkBH6cbM5c94od3VG4QmxHkXJzm6VEFxogbQKpd7gERJNgESyu1gEjLJnPiUer59sXnx787eB6hYBkhdkFnzdL✔ Create Token Accounts and Mint Tokens (354ms)Extra accounts meta: nullTransaction Signature: 4T6FS3Y95Kjkf9fy5jtCYWo2Wf1SSQKmo6GUK2YqXEcgR4Wrr6aLmnoEBcBNCpEv4ALbJuwu5KtVdxb1S3ynMPJY✔ Create ExtraAccountMetaList Account (695ms)Extra accounts meta: 9mifVeGPh7CHyf1NrcUWzzVKMU7g3AwQ6L3md3fMNqjuCounter PDa: 334HLdMwbhSGYf8QWHHmEkeZf6x6caXGF6oxVnCEmaQdTransfer Signature: 32zoL4oTC3XPVsgeDmT3KsTS4v8U4qe3GPKMF72QX5eSHgAFagKEyvRrGuoP2UEGLpj41Ygm9dSRi5YKghxS24EN✔ Transfer Hook with Extra Account Meta (776ms)4 passing (2s)
由于我们在每次代币转账时都会递增计数器,因此需要确保 Transfer Hook 指令只能在转账过程中被调用,否则有人可能直接调用 Transfer Hook 指令来篡改我们的计数器。这是您应该添加到所有 Transfer Hook 中的检查项。
您可以像这样添加该检查:
fn assert_is_transferring(ctx: &Context<TransferHook>) -> Result<()> {let source_token_info = ctx.accounts.source_token.to_account_info();let mut account_data_ref: RefMut<&mut [u8]> = source_token_info.try_borrow_mut_data()?;let mut account = PodStateWithExtensionsMut::<PodAccount>::unpack(*account_data_ref)?;let account_extension = account.get_extension_mut::<TransferHookAccount>()?;if !bool::from(account_extension.transferring) {return err!(TransferError::IsNotCurrentlyTransferring);}Ok(())}
然后在您的 transfer_hook 函数开头调用它:
#[error_code]pub enum TransferError {#[msg("The token is not currently transferring")]IsNotCurrentlyTransferring,}#[interface(spl_transfer_hook_interface::execute)]pub fn transfer_hook(ctx: Context<TransferHook>, _amount: u64) -> Result<()> {// Fail this instruction if it is not called from within a transfer hookassert_is_transferring(&ctx)?;ctx.accounts.counter_account.counter.checked_add(1).unwrap();msg!("This token has been transferred {0} times", ctx.accounts.counter_account.counter);Ok(())}
带 wSOL 转账手续费的 Transfer Hook(进阶示例)
在本指南的下一部分,我们将使用 Anchor 框架构建一个更高级的 Transfer Hook 程序。该程序将要求发送方在每次代币转账时支付 wSOL 手续费。
wSOL 转账将通过一个委托方执行,该委托方是从 Transfer Hook 程序派生的 PDA。这是必要的,因为代币转账指令的初始发送方签名在 Transfer Hook 程序中不可访问。
此程序仅包含 3 条指令:
initialize_extra_account_meta_list:创建一个账户,用于存储transfer_hook指令所需的额外账户列表。transfer_hook:此指令在每次代币转账时通过 CPI 调用,用于执行包装 SOL 代币转账。fallback:Transfer Hook 接口指令具有特定的鉴别器(指令标识符)。在 Anchor 程序中,我们可以使用 fallback 指令手动匹配指令鉴别器并调用我们自定义的transfer_hook指令。
此程序将要求发送方在每次代币转账时支付包装 SOL(wSOL)手续费。以下是 最终程序。
快速开始
首先打开此 Solana Playground 链接 ,然后点击「Import」按钮复制该项目。
起始代码包含 lib.rs 和 transfer-hook.test.ts 文件,这些文件是我们将要创建的程序的脚手架。在 lib.rs 文件中,您应该能看到以下代码:
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!("E6wu6Nykdra8gXs57Zqo7hY6DLaWugTmD3uuuBmX2Vxt");#[program]pub mod transfer_hook {use super::*;pub fn initialize_extra_account_meta_list(ctx: Context<InitializeExtraAccountMetaList>,) -> Result<()> {Ok(())}pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {Ok(())}pub fn fallback<'info>(program_id: &Pubkey,accounts: &'info [AccountInfo<'info>],data: &[u8],) -> Result<()> {Ok(())}}#[derive(Accounts)]pub struct InitializeExtraAccountMetaList {}#[derive(Accounts)]pub struct TransferHook {}
导入项目后,在 Playground 终端中使用 build 命令构建程序。
build
这将使用新生成的程序 ID 更新 lib.rs 文件中 declare_id 的值。
初始化 ExtraAccountMetas 账户指令
在此步骤中,我们将为 Transfer Hook 程序实现 initialize_extra_account_meta_list 指令。该指令创建一个 ExtraAccountMetas 账户,用于存储 transfer_hook 指令所需的额外账户。
在此示例中,initialize_extra_account_meta_list 指令需要 7 个账户:
payer:用于支付创建 ExtraAccountMetas 账户费用的账户。extra_account_meta_list:创建用于存储transfer_hook指令所需账户列表的 ExtraAccountMetas 账户。mint:指向此 Transfer Hook 程序的 mint account。mint 地址是派生extra_account_meta_listPDA 的必需 seed。wsol_mint:包装 SOL 的 mint。token_program:原始 Token Program ID。associated_token_program:Associated Token Program ID。system_program:System Program,在创建新账户时是必需账户。
mint、wsol_mint 和 associated_token_program 的地址将用于派生 wSOL associated token account 的地址。这些账户由 transfer_hook 指令所需,并将存储在 ExtraAccountMetas 账户中。
通过替换以下起始代码来更新 InitializeExtraAccountMetaList 结构体:
#[derive(Accounts)]pub struct InitializeExtraAccountMetaList {}
替换为以下提供的代码:
#[derive(Accounts)]pub struct InitializeExtraAccountMetaList<'info> {#[account(mut)]payer: Signer<'info>,/// CHECK: ExtraAccountMetaList Account, must use these 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>,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>,}
接下来,通过替换以下起始代码来更新 initialize_extra_account_meta_list 指令:
pub fn initialize_extra_account_meta_list(ctx: Context<InitializeExtraAccountMetaList>,) -> Result<()> {Ok(())}
替换为以下代码:
pub fn initialize_extra_account_meta_list(ctx: Context<InitializeExtraAccountMetaList>,) -> Result<()> {// index 0-3 are the accounts required for token transfer (source, mint, destination, owner)// index 4 is address of ExtraAccountMetaList account// The `addExtraAccountsToInstruction` JS helper function resolving incorrectlylet account_metas = vec![// index 5, wrapped SOL mintExtraAccountMeta::new_with_pubkey(&ctx.accounts.wsol_mint.key(), false, false)?,// index 6, token programExtraAccountMeta::new_with_pubkey(&ctx.accounts.token_program.key(), false, false)?,// index 7, associated token programExtraAccountMeta::new_with_pubkey(&ctx.accounts.associated_token_program.key(),false,false,)?,// index 8, delegate PDAExtraAccountMeta::new_with_seeds(&[Seed::Literal {bytes: "delegate".as_bytes().to_vec(),}],false, // is_signerfalse, // is_writable)?,// index 9, delegate wrapped SOL token accountExtraAccountMeta::new_external_pda_with_seeds(7, // associated token program index&[Seed::AccountKey { index: 8 }, // owner index (delegate PDA)Seed::AccountKey { index: 6 }, // token program indexSeed::AccountKey { index: 5 }, // wsol mint index],false, // is_signertrue, // is_writable)?,// index 10, sender wrapped SOL token accountExtraAccountMeta::new_external_pda_with_seeds(7, // associated token program index&[Seed::AccountKey { index: 3 }, // owner indexSeed::AccountKey { index: 6 }, // token program indexSeed::AccountKey { index: 5 }, // wsol mint index],false, // is_signertrue, // is_writable)?,];// calculate account sizelet account_size = ExtraAccountMetaList::size_of(account_metas.len())? as u64;// calculate minimum required lamportslet 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 ExtraAccountMetaList accountcreate_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 ExtraAccountMetaList account with extra accountsExtraAccountMetaList::init::<ExecuteInstruction>(&mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,&account_metas,)?;Ok(())}
让我们逐步了解更新后的指令逻辑。我们首先列出需要存储在 ExtraAccountMetas 账户中的额外账户。
// index 0-3 are the accounts required for token transfer (source, mint, destination, owner)// index 4 is address of ExtraAccountMetaList account// The `addExtraAccountsToInstruction` JS helper function resolving incorrectlylet account_metas = vec![// index 5, wrapped SOL mintExtraAccountMeta::new_with_pubkey(&ctx.accounts.wsol_mint.key(), false, false)?,// index 6, token programExtraAccountMeta::new_with_pubkey(&ctx.accounts.token_program.key(), false, false)?,// index 7, associated token programExtraAccountMeta::new_with_pubkey(&ctx.accounts.associated_token_program.key(),false,false,)?,// index 8, delegate PDAExtraAccountMeta::new_with_seeds(&[Seed::Literal {bytes: "delegate".as_bytes().to_vec(),}],false, // is_signertrue, // is_writable)?,// index 9, delegate wrapped SOL token accountExtraAccountMeta::new_external_pda_with_seeds(7, // associated token program index&[Seed::AccountKey { index: 8 }, // owner index (delegate PDA)Seed::AccountKey { index: 6 }, // token program indexSeed::AccountKey { index: 5 }, // wsol mint index],false, // is_signertrue, // is_writable)?,// index 10, sender wrapped SOL token accountExtraAccountMeta::new_external_pda_with_seeds(7, // associated token program index&[Seed::AccountKey { index: 3 }, // owner indexSeed::AccountKey { index: 6 }, // token program indexSeed::AccountKey { index: 5 }, // wsol mint index],false, // is_signertrue, // is_writable)?,];
存储这些账户有三种方式:
- 直接存储账户地址:
- 包装 SOL 的 mint 地址
- Token Program ID
- Associated Token Program ID
// index 5, wrapped SOL mintExtraAccountMeta::new_with_pubkey(&ctx.accounts.wsol_mint.key(), false, false)?,// index 6, token programExtraAccountMeta::new_with_pubkey(&ctx.accounts.token_program.key(), false, false)?,// index 7, associated token programExtraAccountMeta::new_with_pubkey(&ctx.accounts.associated_token_program.key(),false,false,)?,
- 存储用于为 Transfer Hook 程序派生 PDA 的 seed:
- 委托方 PDA
// index 8, delegate PDAExtraAccountMeta::new_with_seeds(&[Seed::Literal {bytes: "delegate".as_bytes().to_vec(),}],false, // is_signerfalse, // is_writable)?,
- 存储用于为Transfer Hook程序以外的程序派生PDA的seed:
- 委托人 wSOL Associated Token Account
- 发送方 wSOL Associated Token Account
// index 9, delegate wrapped SOL token accountExtraAccountMeta::new_external_pda_with_seeds(7, // associated token program index&[Seed::AccountKey { index: 8 }, // owner index (delegate PDA)Seed::AccountKey { index: 6 }, // token program indexSeed::AccountKey { index: 5 }, // wsol mint index],false, // is_signertrue, // is_writable)?,// index 10, sender wrapped SOL token accountExtraAccountMeta::new_external_pda_with_seeds(7, // associated token program index&[Seed::AccountKey { index: 3 }, // owner indexSeed::AccountKey { index: 6 }, // token program indexSeed::AccountKey { index: 5 }, // wsol mint index],false, // is_signertrue, // is_writable)?,
接下来,我们计算存储ExtraAccountMetas列表所需的大小和rent。
// calculate account sizelet account_size = ExtraAccountMetaList::size_of(account_metas.len())? as u64;// calculate minimum required lamportslet lamports = Rent::get()?.minimum_balance(account_size as usize);
接下来,我们向System Program发起CPI,创建一个账户并将Transfer Hook Program设置为所有者。由于我们使用PDA作为新账户的地址,因此在CPI中将PDA的seed作为签名seed包含在内。
let mint = ctx.accounts.mint.key();let signer_seeds: &[&[&[u8]]] = &[&[b"extra-account-metas",&mint.as_ref(),&[ctx.bumps.extra_account_meta_list],]];// create ExtraAccountMetaList accountcreate_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,)?;
创建账户后,我们初始化账户数据以存储ExtraAccountMetas列表。
// initialize ExtraAccountMetaList account with extra accountsExtraAccountMetaList::init::<ExecuteInstruction>(&mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,&account_metas,)?;
在本示例中,我们没有使用Transfer Hook接口来创建ExtraAccountMetas账户。
自定义Transfer Hook指令
接下来,让我们实现自定义的transfer_hook指令。这是Token Extension程序在每次代币转账时调用的指令。
在本示例中,我们将要求每次代币转账时支付一笔以wSOL计价的费用。为简化起见,费用金额等于代币转账金额。
将以下初始代码替换,以更新TransferHook结构体:
#[derive(Accounts)]pub struct TransferHook {}
替换为以下更新后的代码:
请注意,此结构体中账户的顺序非常重要。这是Token Extensions程序在CPI调用此Transfer Hook程序时提供账户的顺序。
// Order of accounts matters for this struct.// The first 4 accounts are the accounts required for token transfer (source, mint, destination, owner)// Remaining accounts are the extra accounts required from the ExtraAccountMetaList account// These accounts are provided via CPI to this program from the token2022 program#[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 programpub owner: UncheckedAccount<'info>,/// CHECK: ExtraAccountMetaList Account,#[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(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>,}
前4个账户是初始代币转账所需的账户。
#[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 programpub owner: UncheckedAccount<'info>,
第5个账户是ExtraAccountMeta账户的地址,该账户存储了我们transfer_hook指令所需的额外账户列表。
/// CHECK: ExtraAccountMetaList Account#[account(seeds = [b"extra-account-metas", mint.key().as_ref()],bump)]pub extra_account_meta_list: UncheckedAccount<'info>,
其余账户是ExtraAccountMetas账户中列出的账户,顺序与我们在initialize_extra_account_meta_list指令中定义的顺序一致。
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>,
接下来,将以下初始代码替换,以更新transfer_hook指令:
pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {Ok(())}
替换为以下更新后的代码:
// Require SOL fee on transfer, lamport fee is equal to transfer amount// If this fails, the initial token transfer failspub 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 WSOL from sender to delegate token account using delegate PDAtransfer_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(())}
在指令逻辑中,我们发起CPI从发送方的wSOL token account转出wSOL。此次转账由委托人PDA签名授权。每次代币转账时,发送方必须先授权委托人转账相应金额。
Fallback指令
最后,我们需要在Anchor程序中添加一条fallback指令,以处理来自Token Extensions程序的CPI。
此步骤是必要的,因为Anchor生成指令鉴别器的方式与Transfer Hook接口指令所使用的方式存在差异。transfer_hook指令的指令鉴别器与Transfer Hook接口的鉴别器不匹配。
将以下初始代码替换,以更新fallback指令:
pub fn fallback<'info>(program_id: &Pubkey,accounts: &'info [AccountInfo<'info>],data: &[u8],) -> Result<()> {Ok(())}
替换为以下更新后的代码:
// fallback instruction handler as workaround to anchor instruction discriminator checkpub 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 transfermatch 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()),}}
fallback指令会检查传入指令的指令鉴别器是否与Transfer Hook接口中的Execute指令匹配。如果匹配成功,则调用我们Anchor程序中的transfer_hook指令。
目前,Anchor有一个尚未发布的功能可以简化此流程,届时将无需fallback指令。
构建并部署程序
Transfer Hook程序现已完成。请确保您的Playground钱包中有足够的Devnet SOL以部署该程序。
要构建程序,请使用以下命令:
build
接下来,使用以下命令部署程序:
deploy
测试文件概览
接下来,让我们测试该程序。打开transfer-hook.test.ts文件,您应该会看到以下初始代码:
import * as anchor from "@coral-xyz/anchor";import { Program } from "@coral-xyz/anchor";import { TransferHook } from "../target/types/transfer_hook";import {PublicKey,SystemProgram,Transaction,sendAndConfirmTransaction,Keypair,} from "@solana/web3.js";import {ExtensionType,TOKEN_2022_PROGRAM_ID,getMintLen,createInitializeMintInstruction,createInitializeTransferHookInstruction,addExtraAccountsToInstruction,ASSOCIATED_TOKEN_PROGRAM_ID,createAssociatedTokenAccountInstruction,createMintToInstruction,createTransferCheckedInstruction,getAssociatedTokenAddressSync,createApproveInstruction,createSyncNativeInstruction,NATIVE_MINT,TOKEN_PROGRAM_ID,getAccount,getOrCreateAssociatedTokenAccount,} from "@solana/spl-token";import assert from "assert";describe("transfer-hook", () => {// Configure the client to use the local cluster.const provider = anchor.AnchorProvider.env();anchor.setProvider(provider);const program = anchor.workspace.TransferHook as Program<TransferHook>;const wallet = provider.wallet as anchor.Wallet;const connection = provider.connection;// Generate keypair to use as address for the transfer-hook enabled mintconst mint = new Keypair();const decimals = 9;// Sender token account addressconst sourceTokenAccount = getAssociatedTokenAddressSync(mint.publicKey,wallet.publicKey,false,TOKEN_2022_PROGRAM_ID,ASSOCIATED_TOKEN_PROGRAM_ID);// Recipient token account addressconst recipient = Keypair.generate();const destinationTokenAccount = getAssociatedTokenAddressSync(mint.publicKey,recipient.publicKey,false,TOKEN_2022_PROGRAM_ID,ASSOCIATED_TOKEN_PROGRAM_ID);// ExtraAccountMetaList address// Store extra accounts required by the custom transfer hook instructionconst [extraAccountMetaListPDA] = PublicKey.findProgramAddressSync([Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()],program.programId);// PDA delegate to transfer wSOL tokens from senderconst [delegatePDA] = PublicKey.findProgramAddressSync([Buffer.from("delegate")],program.programId);// Sender wSOL token account addressconst senderWSolTokenAccount = getAssociatedTokenAddressSync(NATIVE_MINT, // mintwallet.publicKey // owner);// Delegate PDA wSOL token account address, to receive wSOL tokens from senderconst delegateWSolTokenAccount = getAssociatedTokenAddressSync(NATIVE_MINT, // mintdelegatePDA, // ownertrue // allowOwnerOffCurve);// Create the two WSol token accounts as part of setupbefore(async () => {// WSol Token Account for senderawait getOrCreateAssociatedTokenAccount(connection,wallet.payer,NATIVE_MINT,wallet.publicKey);// WSol Token Account for delegate PDAawait getOrCreateAssociatedTokenAccount(connection,wallet.payer,NATIVE_MINT,delegatePDA,true);});it("Create Mint Account with Transfer Hook Extension", async () => {});it("Create Token Accounts and Mint Tokens", async () => {});it("Create ExtraAccountMetaList Account", async () => {});it("Transfer Hook with Extra Account Meta", async () => {});});
首先,我们生成一个keypair,用作新Mint Account的地址。利用mint地址,我们派生出将用于代币转账的Associated Token Account(ATA)地址。
// Generate keypair to use as address for the transfer-hook enabled mintconst mint = new Keypair();const decimals = 9;// Sender token account addressconst sourceTokenAccount = getAssociatedTokenAddressSync(mint.publicKey,wallet.publicKey,false,TOKEN_2022_PROGRAM_ID,ASSOCIATED_TOKEN_PROGRAM_ID);// Recipient token account addressconst recipient = Keypair.generate();const destinationTokenAccount = getAssociatedTokenAddressSync(mint.publicKey,recipient.publicKey,false,TOKEN_2022_PROGRAM_ID,ASSOCIATED_TOKEN_PROGRAM_ID);
接下来,我们为ExtraAccountMetas账户派生PDA。该账户用于存储自定义transfer hook指令所需的额外账户。
// ExtraAccountMetaList address// Store extra accounts required by the custom transfer hook instructionconst [extraAccountMetaListPDA] = PublicKey.findProgramAddressSync([Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()],program.programId);
我们还需派生一个用作委托人的PDA。发送方必须将此地址授权为其wSOL token account的委托人。该委托人PDA在自定义transfer hook指令中用于为wSOL转账"签名"。
// PDA delegate to transfer wSOL tokens from senderconst [delegatePDA] = PublicKey.findProgramAddressSync([Buffer.from("delegate")],program.programId);
此外,我们还需派生wSOL token account的地址。第一个地址对应发送方的wSOL token account,需充值资金以支付transfer hook指令所需的转账费用。第二个地址对应由委托人PDA拥有的wSOL token account。在本示例中,所有wSOL费用均发送至该账户。
// Sender wSOL token account addressconst senderWSolTokenAccount = getAssociatedTokenAddressSync(NATIVE_MINT, // mintwallet.publicKey // owner);// Delegate PDA wSOL token account address, to receive wSOL tokens from senderconst delegateWSolTokenAccount = getAssociatedTokenAddressSync(NATIVE_MINT, // mintdelegatePDA, // ownertrue // allowOwnerOffCurve);
最后,作为设置的一部分,我们创建wSOL token account。
// Create the two WSol token accounts as part of setupbefore(async () => {// WSol Token Account for senderawait getOrCreateAssociatedTokenAccount(connection,wallet.payer,NATIVE_MINT,wallet.publicKey);// WSol Token Account for delegate PDAawait getOrCreateAssociatedTokenAccount(connection,wallet.payer,NATIVE_MINT,delegatePDA,true);});
创建Mint Account
首先,构建一笔交易,创建一个启用了Transfer Hook扩展的新Mint Account。在此交易中,请务必将我们的程序指定为扩展中存储的Transfer Hook程序。
启用Transfer Hook扩展后,Transfer Extension程序可以确定在每次代币转账时调用哪个程序。
替换占位测试:
it("Create Mint Account with Transfer Hook Extension", async () => {});
替换为以下更新后的测试:
it("Create Mint Account with Transfer Hook Extension", async () => {const extensions = [ExtensionType.TransferHook];const mintLen = getMintLen(extensions);const lamports =await provider.connection.getMinimumBalanceForRentExemption(mintLen);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 IDTOKEN_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}`);});
创建Token Account
接下来,作为设置的一部分,为发送方和接收方分别创建Associated Token Account,并向发送方账户充入一些代币。
替换占位测试:
it("Create Token Accounts and Mint Tokens", async () => {});
替换为以下更新后的测试:
// Create the two token accounts for the transfer-hook enabled mint// Fund the sender token account with 100 tokensit("Create Token Accounts and Mint Tokens", async () => {// 100 tokensconst 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}`);});
创建ExtraAccountMeta账户
在发送代币转账之前,我们需要创建ExtraAccountMetas账户,以存储transfer hook指令所需的所有额外账户。
要创建此账户,我们调用程序中的相应指令。
替换占位测试:
it("Create ExtraAccountMetaList Account", async () => {});
替换为以下更新后的测试:
// Account to store extra accounts required by the transfer hook instructionit("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 });console.log("Transaction Signature:", txSig);});
转账代币
最后,我们准备好发送代币转账。除转账指令外,还需包含几条额外指令。
- 发送方必须向其wSOL token account转入SOL,以支付transfer hook指令所需的费用。
- 发送方必须为wSOL费用金额授权委托人PDA。
- 包含一条同步wSOL余额的指令。
- 代币转账指令必须包含transfer hook指令所需的所有额外账户。
替换占位测试:
it("Transfer Hook with Extra Account Meta", async () => {});
替换为以下更新后的测试:
it("Transfer Hook with Extra Account Meta", async () => {// 1 tokensconst amount = 1 * 10 ** decimals;const amountBigInt = BigInt(amount);// Instruction for sender to fund their WSol token accountconst solTransferInstruction = SystemProgram.transfer({fromPubkey: wallet.publicKey,toPubkey: senderWSolTokenAccount,lamports: amount});// Approve delegate PDA to transfer WSol tokens from sender WSol token accountconst approveInstruction = createApproveInstruction(senderWSolTokenAccount,delegatePDA,wallet.publicKey,amount,[],TOKEN_PROGRAM_ID);// Sync sender WSol token accountconst syncWrappedSolInstruction = createSyncNativeInstruction(senderWSolTokenAccount);// This helper function will automatically derive all the additional accounts that were defined in the ExtraAccountMetas accountlet transferInstructionWithHelper =await createTransferCheckedWithTransferHookInstruction(connection,sourceTokenAccount,mint.publicKey,destinationTokenAccount,wallet.publicKey,amountBigInt,decimals,[],"confirmed",TOKEN_2022_PROGRAM_ID);const transaction = new Transaction().add(solTransferInstruction,syncWrappedSolInstruction,approveInstruction,transferInstructionWithHelper);const txSig = await sendAndConfirmTransaction(connection,transaction,[wallet.payer],{ skipPreflight: true });console.log("Transfer Signature:", txSig);});
转账指令必须包含所有额外的AccountMeta、ExtraAccountMetas账户的地址以及Transfer Hook程序的地址。
运行测试文件
更新所有测试后,最后一步是运行测试。
要运行测试文件,请在终端中使用以下命令:
test
您应该会看到类似如下的输出:
Running tests...transfer-hook.test.ts:transfer-hookTransaction Signature: 5o12ZTvcSkV8YNqyeQpzRCq4zFSg9VqguQkT9ZSesioj8uzb8dWRheoknuPaRDDqEGdrUBqmRQ2veSUshUicWsqG✔ Create Mint Account with Transfer Hook Extension (996ms)Transaction Signature: 4F4Vhi8s1h2reDr6jecvuQFF5XpoofWPpshgAMnfg7jtNZj4HtxbsksFTh28ZjYTaKFpjeturYZKxk5Cj4gBZoy✔ Create Token Accounts and Mint Tokens (716ms)Transaction Signature: 3s4Nok6H4qexpGXup3AWC4nGuiqy567rm5rTWLFMXYKxZJensBVVZHCwVDzpwD3XtWjMFHm4TrvQXwKSsp47y5jx✔ Create ExtraAccountMetaList Account (711ms)Transfer Signature: 53j9QV5LYUVgV7T7Z99GfYg1Xvp2qbQnHsJbzDK6BR5TPBo9s622KCf3W3BDEL4ECprkZFs5biDRDedfVj6zuDA6✔ Transfer Hook with Extra Account Meta (925ms)4 passing (5s)
在transfer hook中使用token account数据
有时您可能希望使用账户数据来派生额外账户中的附加账户。例如,如果您想将token account的所有者作为PDA的seed,这将非常有用。
在创建ExtraAccountMeta时,您可以使用任意账户的数据作为额外seed。在本示例中,我们希望从token account所有者和字符串'counter'派生一个计数器账户。这意味着我们将始终能够看到该token account所有者转账代币的次数。
以下是在extra_account_metas()函数中进行设置的方法。
// Define extra account metas to store on extra_account_meta_list accountimpl<'info> InitializeExtraAccountMetaList<'info> {pub fn extra_account_metas() -> Result<Vec<ExtraAccountMeta>> {Ok(vec![ExtraAccountMeta::new_with_seeds(&[Seed::Literal {bytes: b"counter".to_vec(),},Seed::AccountData { account_index: 0, data_index: 32, length: 32 },],false, // is_signertrue // is_writable)?])}}
让我们来看一下token account结构体,以了解账户数据的存储方式。下面是一个token account结构的示例。我们可以取位置32到64的32个字节作为token account的所有者,对应'account_index: 0'。'account_index'表示该账户在账户数组中的索引。在transfer hook的情况下,所有者token account是账户数组中的第一个条目,第二个账户始终是mint,第三个账户是目标token account。此账户顺序与旧版token program中的顺序相同。
/// Account data.#[repr(C)]#[derive(Clone, Copy, Debug, Default, PartialEq)]pub struct Account {/// The mint associated with this accountpub mint: Pubkey,/// The owner of this account.pub owner: Pubkey,/// The amount of tokens this account holds.pub amount: u64,pub delegate: COption<Pubkey>,pub state: AccountState,pub is_native: COption<u64>,pub delegated_amount: u64,pub close_authority: COption<Pubkey>,}
在我们的示例中,我们希望从发送方token account的所有者派生一个计数器账户,因此在创建ExtraAccountMeta账户时,我们init这个由发送方token account所有者和字符串'counter'派生的PDA计数器账户。初始化PDA计数器账户后,我们便可以在transfer hook中使用它,在每次转账时对其值进行递增。
struct.```rust#[derive(Accounts)]pub struct InitializeExtraAccountMetaList<'info> {#[account(mut)]payer: Signer<'info>,/// CHECK: ExtraAccountMetaList Account, must use these seeds#[account(init,seeds = [b"extra-account-metas", mint.key().as_ref()],bump,space = ExtraAccountMetaList::size_of(InitializeExtraAccountMetaList::extra_account_metas()?.len())?,payer = payer)]pub extra_account_meta_list: AccountInfo<'info>,pub mint: InterfaceAccount<'info, Mint>,#[account(init, seeds = [b"counter", payer.key().as_ref()], bump, payer = payer, space = 16)]pub counter_account: Account<'info, CounterAccount>,pub token_program: Program<'info, Token2022>,pub associated_token_program: Program<'info, AssociatedToken>,pub system_program: Program<'info, System>,}
我们还需要在TransferHook结构体中定义这个额外的计数器账户。这些是每次执行转账时传递给TransferHook程序的账户。客户端从ExtraAccountsMetaList PDA获取这些额外账户,并将其包含在代币转账指令中,但在程序中我们仍需对其进行定义。
#[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 programpub 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(seeds = [b"counter", owner.key().as_ref()], bump)]pub counter_account: Account<'info, CounterAccount>,}
在客户端中,此账户是自动生成的,您可以按如下方式使用它。
const transferInstructionWithHelper =await createTransferCheckedWithTransferHookInstruction(connection,sourceTokenAccount,mint.publicKey,destinationTokenAccount,wallet.publicKey,amountBigInt,decimals,[],"confirmed",TOKEN_2022_PROGRAM_ID);
该辅助函数会从ExtraAccounts数据账户中自动解析该账户。在客户端中,账户的解析方式如下:
const [counterPDA] = PublicKey.findProgramAddressSync([Buffer.from("counter"), wallet.publicKey.toBuffer()],program.programId);
请注意,计数器账户是从token account的所有者派生的,需要在执行转账之前完成初始化。在本示例中,我们在初始化extra account metas时初始化计数器账户。因此,我们只会为调用该函数的token account所有者创建一个计数器PDA。如果您希望为您的mint下的每个token account都创建计数器账户,则需要提前实现创建这些PDA的功能。您可以在dapp上设置一个"注册计数器"按钮,用于创建该PDA账户,此后用户便可使用这个计数器代币。
总结
Transfer Hook扩展和Transfer Hook接口允许创建在每次代币转账时执行自定义指令逻辑的Mint Account。本指南可作为参考,帮助您创建自己的Transfer Hook程序。欢迎发挥创意,探索这项新功能的各种可能性!
Is this page helpful?