Jacky Gu

Solana开发笔记: Token2022 - 使用Transfer hook扩展设置转账条件

01 Jan 2025 Share to

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_metasExtraAccountMetaList账户
  • 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时,测试转账成功,如下图: