Token2022 - 使用Transfer hook扩展设置转账条件
Transfer hook
是另一个程序,在hook程序中规定了代币转账的逻辑,然后在主程序中配置该hook程序成为Transfer Hook,每次转账时,会运行该hook中的代码。
场景描述:
在主程序中有一个TokenConfigData
的状态数据,其中包含了另一个TokenMintState
的状态,如下:
#[account]
pub struct TokenConfigData {
pub target_eras: u32,
pub mint_state_data: TokenMintState,
pub admin: Pubkey,
pub token_id: u64,
}
#[account]
pub struct TokenMintState {
pub supply: u64,
pub current_era: u32,
}
在转移代币的时候,需要满足的条件:
- TokenMintState中的current_era > TokenConfigData的target_eras
- TokenMintState中的supply > 100000 * 1000000000
Tranfer hook程序
首先,创建一个新的程序。
因为这个hook程序比较简单,可以把所有代码都写在lib.rs
中,以下是所有程序代码:
use std::cell::RefMut;
use anchor_lang::{
prelude::*,
system_program::{create_account, CreateAccount},
};
use anchor_spl::{
associated_token::AssociatedToken,
token_2022::spl_token_2022::{
extension::{
transfer_hook::TransferHookAccount,
BaseStateWithExtensionsMut,
PodStateWithExtensionsMut,
},
pod::PodAccount,
},
token_interface::{
Mint, Token2022, TokenAccount
},
};
use spl_tlv_account_resolution::{ account::ExtraAccountMeta, state::ExtraAccountMetaList };
use spl_transfer_hook_interface::instruction::{ExecuteInstruction, TransferHookInstruction};
declare_id!("42vSRo3L5Xu46DUQa29VnnUVqMN8ur8XEFTRw6KGmvmo");
#[error_code]
pub enum TransferError {
#[msg("The token is not currently transferring")]
IsNotCurrentlyTransferring,
}
#[program]
pub mod transfer_hook {
use super::*;
#[interface(spl_transfer_hook_interface::initialize_extra_account_meta_list)]
pub fn initialize_extra_account_meta_list(ctx: Context<InitializeExtraAccountMetaList>) -> Result<()> {
// 1- Initialize ExtraAccountMetaList manually
let extra_account_metas = vec![
ExtraAccountMeta::new_with_pubkey(
&ctx.accounts.config_account.key(),
false, // is_signer
false // is_writable
)?,
];
// 2- Create ExtraAccountMetaList account
// calculate account size
let account_size = ExtraAccountMetaList::size_of(extra_account_metas.len())? as u64;
// calculate minimum required lamports
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 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,
)?;
// 3- Initialize ExtraAccountMetaList account with extra accounts
ExtraAccountMetaList::init::<ExecuteInstruction>(
&mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,
&extra_account_metas
)?;
Ok(())
}
#[interface(spl_transfer_hook_interface::execute)]
pub fn transfer_hook(ctx: Context<TransferHook>, _amount: u64) -> Result<()> {
check_is_transferring(&ctx)?;
// Deserialize the config account data
let config_data = &mut &ctx.accounts.config_account.try_borrow_data()?[..];
// target eras (u32 - 4 bytes)
let target_eras = u32::from_le_bytes(config_data[8..12].try_into().unwrap());
// current era (u32 - 4 bytes)
let current_era = u32::from_le_bytes(config_data[20..24].try_into().unwrap());
if current_era > target_eras {
msg!("Transfer hook passed!");
Ok(())
} else {
panic!("Transfer forbidden in target eras!");
}
}
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()),
}
}
}
fn check_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(())
}
#[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: UncheckedAccount<'info>,
pub mint: InterfaceAccount<'info, Mint>,
pub token_program: Program<'info, Token2022>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub system_program: Program<'info, System>,
/// CHECK: Config account owned by main program
pub config_account: UncheckedAccount<'info>,
}
#[derive(Accounts)]
pub struct TransferHook<'info> {
#[account(token::mint = mint, token::authority = owner)]
pub source_token: InterfaceAccount<'info, TokenAccount>, // index - 0: source ata
pub mint: InterfaceAccount<'info, Mint>, // index - 1: mint
#[account(token::mint = mint)]
pub destination_token: InterfaceAccount<'info, TokenAccount>, // index - 2: destination ata
/// CHECK: source token account owner, can be SystemAccount or PDA owned by another program
pub owner: UncheckedAccount<'info>, // index - 3: owner
/// CHECK: ExtraAccountMetaList Account,
#[account(seeds = [b"extra-account-metas", mint.key().as_ref()], bump)]
pub extra_account_meta_list: UncheckedAccount<'info>, // index - 4: extra_account_meta_list
/// CHECK: Config account owned by main program
pub config_account: UncheckedAccount<'info>, // index - 5: config account
}
解释:
- 1- 在
initialize_extra_account_meta_list()
中,分散步完成extra_account_meta_list
账户的初始化:- 初始化
extra_account_metas
- 创建
ExtraAccountMetaList
账户 - 绑定
extra_account_metas
和ExtraAccountMetaList
账户
- 初始化
- 2- 在
transfer_hook()
中,对传入的config_account
账户中的数据进行反序列化,从账户中提取出需要的值:let config_data = &mut &ctx.accounts.config_account.try_borrow_data()?[..]; // target eras (u32 - 4 bytes), from No.8 let target_eras = u32::from_le_bytes(config_data[8..12].try_into().unwrap()); // current era (u32 - 4 bytes), from No.20 let current_era = u32::from_le_bytes(config_data[20..24].try_into().unwrap());
- 3- 在
InitializeExtraAccountMetaList
账户和TransferHook
账户中,添加config_account
作为额外账户。
主程序的初始化代币指令中,链接Transfer hook程序
在mint账户中,添加transfer_hook
相关的两条anchor指令,anchor会自动链接Transfer hook程序。
#[account(
init,
payer = payer,
seeds = [MINT_SEEDS.as_bytes(), metadata_params.name.as_bytes(), metadata_params.symbol.as_bytes()],
bump,
mint::token_program = token_program,
mint::decimals = 0,
mint::authority = mint,
mint::freeze_authority = mint,
extensions::metadata_pointer::authority = mint,
extensions::metadata_pointer::metadata_address = mint,
extensions::transfer_hook::authority = mint, // <--
extensions::transfer_hook::program_id = TRANSFER_HOOK_ADDRESS // <--
)]
pub mint: Box<InterfaceAccount<'info, Mint>>,
另外,为了测试Transfer hook转账,需要在初始化Token程序中添加以下几个账户和指令:
- 1-
config_account
, 与在hook程序中的config_account对应。即在主程序中修改账户数据,被hook程序中的转账钩子获取并检查是否可以转账。#[account( init, payer = payer, seeds = [b"config", mint.key().as_ref()], bump, space = 8 + TokenConfigData::INIT_SPACE, )] pub config_account: Box<Account<'info, TokenConfigData>>,
- 2-
payer_ata
,这是部署代币人的TokenAccount,也就是初始化代币后,立即将所有代币转给部署人,供测试之后的转币操作。#[account( init_if_needed, payer = payer, associated_token::mint = mint, associated_token::authority = payer, )] pub payer_ata: Box<InterfaceAccount<'info, TokenAccount>>,
- 3- 将所有代币铸造给
payer_ata
的指令:// mint all supply amount to the deployer let seeds = &[MINT_SEEDS.as_bytes(), metadata_params.name.as_bytes(), metadata_params.symbol.as_bytes(), &[ctx.bumps.mint]]; let signer = [&seeds[..]]; mint_to( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), MintTo { mint: ctx.accounts.mint.to_account_info(), to: ctx.accounts.payer_ata.to_account_info(), authority: ctx.accounts.mint.to_account_info(), }, &signer, ), supply.try_into().unwrap(), )?;
- 4- 在指令中,增加测试hook的参数,即
target_era
,current_era
,supply
,并保存到config_account
中。// Initialize config account let config_account = &mut ctx.accounts.config_account; config_account.target_eras = target_era; config_account.mint_state_data.supply = supply; config_account.mint_state_data.current_era = current_era;
编译部署
需要先部署Transfer Hook程序,获取hook程序的program id
,然后填入主程序constant.rs
中的常量TRANSFER_HOOK_ADDRESS
,再部署主程序。
编写测试代码
测试代码分为四部分:
- 1- 部署一个带Transfer Hook扩展的代币Mint铸币厂程序,部署时将参数设为:
targetEra=2; currentEra=1; supply=10000
。 - 2- 测试转账,因为hook程序中规定
currentEra
大于等于targetEra
时,才会允许转账,所以这次转账将失败。 - 3- 再部署一个Transfer Hook扩展的代币Mint铸币厂程序,这次部署时将参数设为:
targetEra=2; currentEra=3; supply=10000
。 - 4- 测试转账,因为hook程序中规定
currentEra
大于等于targetEra
时,才会允许转账,所以这次转账成功。
全部测试代码如下:
describe("Test Transfer hook extension", async () => {
const transferHookProgram = anchor.workspace.TransferHook as Program<TransferHook>;
const initializeTokenWithTransferHook = async (metadata: any, targetEra: number, currentEra: number) => {
const mintAccount = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("mint"), Buffer.from(metadata.name), Buffer.from(metadata.symbol)],
program.programId,
)[0];
const configAccount = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("config"), mintAccount.toBuffer()],
program.programId,
)[0];
const extraAccountMetaList = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from(EXTRA_ACCOUNT_META_LIST), mintAccount.toBuffer()],
transferHookProgram.programId,
)[0];
const info = await provider.connection.getAccountInfo(mintAccount);
if (info) {
console.log("Mint account was created, mint account: " + mintAccount.toBase58());
return;
}
const destinationAta = getAssociatedTokenAddressSync(mintAccount, deployerAccount.publicKey, false, TOKEN_2022_PROGRAM_ID);
const contextInitializeToken = { // => context of instruction #3
mint: mintAccount, // ->
payer: deployerAccount.publicKey, // fee payer ->
payerAta: destinationAta,
configAccount: configAccount, // config account ->
tokenProgram: TOKEN_2022_PROGRAM_ID, // ->
transferHookProgramId: transferHookProgram.programId, // program id of transfer hook
};
console.log("Creating ExtraAccountMetaList account: ", extraAccountMetaList.toBase58());
const contextInitializeTransferHook = { // => context of instruction #4
payer: deployerAccount.publicKey,
extraAccountMetaList,
mint: mintAccount,
tokenProgram: TOKEN_2022_PROGRAM_ID,
associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
systemProgram: anchor.web3.SystemProgram.programId,
configAccount: configAccount,
};
try {
const supply = new anchor.BN("100000000000000000");
// if current era < target era, transfer is not allowed
const instructionInitializeToken = await program.methods // ==> instruction #1
.initializeTokenWithTransferHook(metadata, targetEra, currentEra, supply)
.accounts(contextInitializeToken)
.instruction();
// Initialize transfer hook
const instructionInitializeTransferHook = await transferHookProgram.methods // ==> instruction #4
.initializeExtraAccountMetaList()
.accounts(contextInitializeTransferHook)
.instruction();
const transaction = new anchor.web3.Transaction()
.add(instructionInitializeToken) // ===> add instruction #1, init token
.add(instructionInitializeTransferHook); // ===> add instruction #4, init transfer hook
const tx = await provider.sendAndConfirm(transaction, [deployerAccount]);
console.log("Initialized token with transfer hook extension tx: ", tx);
} catch (error) {
console.log(error);
}
}
const transferTokenWithTransferHook = async (metadata: any, from: anchor.web3.Keypair, to: anchor.web3.PublicKey, amount: anchor.BN) => {
const mintAccount = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("mint"), Buffer.from(metadata.name), Buffer.from(metadata.symbol)],
program.programId,
)[0];
const sourceTokenAccount = getAssociatedTokenAddressSync(mintAccount, from.publicKey, false, TOKEN_2022_PROGRAM_ID);
const destinationTokenAccount = await getOrCreateAssociatedTokenAccount(
provider.connection,
from,
mintAccount,
to,
false,
undefined,
undefined,
TOKEN_2022_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID,
);
const balance = (await provider.connection.getTokenAccountBalance(sourceTokenAccount)).value.amount;
if (new anchor.BN(balance).lt(amount)) {
console.log("Insufficient balance");
return;
}
const transferInstruction = await createTransferCheckedWithTransferHookInstruction(
provider.connection,
sourceTokenAccount,
mintAccount,
destinationTokenAccount.address,
from.publicKey,
BigInt(amount.toString()),
9,
[],
"confirmed",
TOKEN_2022_PROGRAM_ID,
);
const transaction = new anchor.web3.Transaction()
transaction.add(transferInstruction);
const signature = await provider.sendAndConfirm(transaction, [from]);
console.log('Transfer tx:', signature);
}
it("Create token mint with transfer hook extension, currentEra < targetEra", async () => {
const symbol = "STT" + Math.floor(Math.random() * 100);
const metadataParams = {
name: "Solana test token",
symbol,
uri: "https://node1.irys.xyz/F7qkMeZGbDwZbbo6E6XkOahuUlGEcXElWgGnH5zm4zQ",
decimals: 9,
};
console.log("Token name", metadataParams.symbol);
await initializeTokenWithTransferHook(metadataParams, 2, 1);
await transferTokenWithTransferHook(metadataParams, deployerAccount, user2Account.publicKey, new anchor.BN(100).mul(new anchor.BN(1000000000)));
})
it("Create token mint with transfer hook extension, currentEra > targetEra", async () => {
const symbol = "STT" + Math.floor(Math.random() * 100);
const metadataParams = {
name: "Solana test token",
symbol,
uri: "https://node1.irys.xyz/F7qkMeZGbDwZbbo6E6XkOahuUlGEcXElWgGnH5zm4zQ",
decimals: 9,
};
console.log("Token name", metadataParams.symbol);
await initializeTokenWithTransferHook(metadataParams, 2, 3);
await transferTokenWithTransferHook(metadataParams, deployerAccount, user2Account.publicKey, new anchor.BN(100).mul(new anchor.BN(1000000000)));
})
})
- 当currentEra <= targetEra时,测试转账出错,反馈错误信息:
"Program log: panicked at programs/transfer-hooks1/src/lib.rs:94:13:\nTransfer forbidden in target eras!",
这个出错信息写在hook程序的
transfer_hook
方法中,如下:if current_era > target_eras { msg!("Transfer hook passed!"); Ok(()) } else { panic!("Transfer forbidden in target eras!"); }
- 当currentEra > targetEra时,测试转账成功,如下图: