Solana 代币 2022 — Transfer Hook

Solana 代币 2022 — Transfer Hook

从零到英雄的 Solana 代币 2022 — Transfer Hook 🪝

照片由 Fabian Blank 拍摄,发布在 Unsplash

Token 2022 计划引入了几项令人兴奋的扩展,增强了铸造和代币账户的功能。在这些功能中,我个人最喜欢的是Transfer Hook (转账钩子) 🪝。

想象时间

让我们戴上想象的帽子,想象一下这个美好的场景:你是一个 NFT 项目的所有者。你向持有者发放代币,以奖励他们将 NFT 抵押给你。你将你的项目视为一个“封闭社区”,这意味着只有你的 NFT 持有者才能持有你的代币。在没有转账钩子之前,你必须构建自己的程序来处理转账,以强制执行这种行为。但有了转账钩子,这就非常简单了!你只需要构建一个转账钩子程序,验证转账的目标地址是否确实是白名单地址之一,如果不是,则停止交易。另一个例子?假设你希望用户在每次进行代币转账时支付额外费用……没问题!这也可以通过构建一个处理转账的钩子来实现!

现在,在 NFT 项目的背景下,这些例子可能看起来有些愚蠢,但想象一下更大的图景,这样的钩子可以帮助遵守监管要求,例如强制执行持有期或设置代币的最大持有量。

Transfer Hook — 它到底是什么?

顾名思义,Transfer Hook 扩展与代币转账密切相关。每当一个铸造配置了 Transfer Hook 时,每次在该铸造上执行转账指令时,都会自动触发一个指令。如果这个概念看起来很熟悉,那是因为它确实如此——这个流程与 web2 中的 webhooks 工作方式非常相似。

转账钩子指令可以访问一个变量,即转账金额,但我们可以提供一个额外的账户(extra-account-meta-list),其中包含指令可以使用的其他自定义账户。我们很快会详细讨论这个账户。

虽然转账钩子确实可以访问初始转账账户,但需要注意的是,它们作为只读账户传递,这意味着发送者的签名权限不会扩展到Transfer Hook程序。

最后,当使用 Anchor 编写Transfer Hook程序时,需要一个回退指令来手动匹配原生程序指令鉴别器并调用Transfer Hook指令。这是因为 Token 2022 计划是一个原生程序,因此我们需要“桥接”其原生接口与 Anchor。

是时候动手了

好了,话不多说——让我们构建一些东西!我们将构建一个“鲸鱼警报”转账钩子 🐋!对于每次代币转账,转账钩子指令将比较转账金额与预定义的值(例如,10,000 代币)。如果等于或大于该值,我们将更新一个账户,记录最新的鲸鱼详情,并发出一个事件供客户端处理。

⚒️ 你将需要安装 Solana CLI 工具和 Anchor。如果你还没有安装,请查看 Anchor 文档 以获取安装说明。

第一步:转账钩子程序

让我们设计我们的转账钩子程序。我们的转账钩子程序首先需要一个指令,可以在转账完成后调用。我们将使用 Anchor 构建我们的程序,然而,Token 2022 计划是一个原生 Solana 程序——因此我们需要另一个指令:一个指令来匹配指令鉴别器到转账钩子的 execute 接口,并在匹配时调用 transfer_hook 指令。所以目前有两个指令。

当我们的钩子被调用时,它会将转账金额与一个值进行比较——但这个值来自哪里?当然,我们可以在程序中硬编码这个值——但如果明天我们决定要增加/减少这个值呢?我们需要重新部署程序。更好的处理方式是将比较值保存在一个账户中。要将额外的账户传递给转账钩子,我们使用 extra_account_meta_list 账户。这是一个 PDA 账户,存储转账钩子在调用时可以使用的账户。extra_account_meta_list 账户将始终使用硬编码字符串 extra-account-metas 和代币的铸造地址作为种子。

现在你可能会问自己,我们如何创建和初始化这个 extra_account_meta_list 账户?这就是我们程序的第三个也是最后一个指令——initialize_extra_account_meta_list 指令的作用。

在完成我们程序的高层设计后,让我们开始实际编写转账钩子程序 🎉。

  1. 创建一个新的 Anchor 项目并在你喜欢的 IDE 中打开它:
anchor init transfer-hook-whale
  1. 安装 anchor-spl, spl-transfer-hook-interfacespl_tlv_account_resolution crates。这些 crates 包含帮助函数和接口,使我们在处理 SPL 代币和扩展指令时更加轻松。
cd programs/transfer-hook-whale
cargo add anchor-spl spl-transfer-hook-interface spl_tlv_account_resolution
  1. 从 Anchor 0.30 开始,我们需要告诉 Anchor 为我们使用的 crates 生成类型定义,因此我们需要告诉它为 anchor-spl crate 生成类型定义。打开 Cargo.toml 文件,位于 programs/transfer-hook-whale 文件夹内,并按如下方式更新 idl-build
[features]
...
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
  1. 打开 lib.rs 并添加以下 use 语句:
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;

一个转账钩子程序通常由三个指令组成:

  • initialize_extra_account_meta — 一个创建账户(extra_account_meta_list)的指令,该账户保存转账钩子可以使用的额外账户列表。
    在我们的例子中,这个账户将保存最新“鲸鱼”地址和金额的详细信息。
  • transfer_hook — 钩子本身。这是每次发生代币转账时实际调用的指令。
  • fallback — Token 2022 计划是一个原生程序,但我们使用 Anchor 构建我们的程序,因此我们需要添加一个回退指令,该指令将指令鉴别器匹配到转账钩子的 execute 接口,并在匹配时调用我们的 transfer_hook 指令。

实现 initialize_extra_account_meta 指令

  1. 在你的 lib.rs 底部,添加以下账户。此账户将保存最新“鲸鱼”转账的详细信息:
#[account]
pub struct WhaleAccount {
    pub whale_address: Pubkey,
    pub transfer_amount: u64
}
  1. 接下来,我们定义 initialize_extra_account_meta 指令执行所需的所有账户。在 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>,
}

🧑‍🏫 提示

  • 将保存额外账户的账户 (extra_account_meta_list) 必须有一个非常特定的种子用于其 PDA:字节串 extra-account-metas 和代币的 mint 账户的公钥。
  • 将保存鲸鱼详细信息的账户 (latest_whale_account) 将有一个简单的种子用于其 PDA:字符串 whale_account 的字节。这意味着我们铸造的所有代币将共享同一个账户。
  • 我们必须为指令提供代币程序、关联代币程序和系统程序,因为它在运行时需要使用它们。
  1. 接下来,让我们添加 initialize_extra_account_meta 指令。用以下内容替换默认的 initialize 指令:
pub fn initialize_extra_account(ctx: Context<InitializeExtraAccountMeta>) -> Result<()> {
  // 这是我们需要的额外账户的向量。在我们的例子中
  // 只有一个账户 - 鲸鱼详细信息账户。
  let account_metas = vec![ExtraAccountMeta::new_with_seeds(
    &[Seed::Literal {
      bytes: "whale_account".as_bytes().to_vec(),
    }],
    false,
    true,
  )?];

  // 计算账户大小和租金
  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();

  // ExtraAccountMetaList PDA 的种子。
  let signer_seeds: &[&[&[u8]]] = &[&[
    b"extra-account-metas",
    &mint.as_ref(),
    &[ctx.bumps.extra_account_meta_list],
  ]];

  // 创建 ExtraAccountMetaList 账户
  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,
  )?;

  // 使用额外账户初始化 ExtraAccountMetaList 账户
  ExtraAccountMetaList::init::<ExecuteInstruction>(
    &mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,
    &account_metas,
  )?;

  Ok(())
}

这里有很多内容,让我们逐步理解:

  1. 首先我们声明一个 ExtraAccountMeta 账户的向量 (account_metas),指定我们将使用的不同额外账户。这些可以从种子、公钥等指定。在我们的例子中,我们只需要一个额外账户——鲸鱼详细信息账户。
  2. 我们计算账户大小,并基于此计算租金所需的 lamports 数量。
  3. 我们指定需要签名的种子为 extra_account_meta_list PDA。
  4. 我们调用 create_account CPI 实际创建 extra_account_meta_list 账户,提供所有需要的数据(付款人、签名者种子、账户大小等)。
  5. 最后,我们用在步骤 1 中声明的额外账户向量初始化新创建的账户。

实现 transfer_hook 指令

我们达到了程序的核心——transfer_hook 指令!这是实际操作发生的地方,因为每当进行交易时都会调用此指令。

  1. 我们首先为指令添加传入账户。在 lib.rsInitializeExtraAccountMeta 下方添加以下内容:
#[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>,
}

🧑‍🏫 提示

  • 这里指定的账户顺序很重要:
    • 前四个账户是代币转账所需的账户(源、铸造、目标和所有者——按此顺序),
    • 第五个账户是 ExtraAccountMetaList 账户的地址。
    • 剩余的账户是 ExtraAccountMetaList 账户所需的额外账户。
  • 注意我们在这里传递的约束——它们是为了帮助我们确保传递的账户确实是我们期望的账户(即正确的铸造账户,正确的所有者等)。
  1. 现在我们可以继续进行实际指令。在 initialize_extra_account 指令下方添加以下代码片段:
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)) {
        // 我们有一个鲸鱼!
        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(())
}

🦁 这里没有太多疯狂的事情:

  • 我们检查转账金额是否大于或等于 1000 代币(这是我选择的任意金额,你可以选择其他金额)。
  • 如果金额确实是 1000 或更多,我们更新鲸鱼详细信息账户的新地址和金额。
  • 最后,我们发出一个名为 WhaleTransferEvent 的事件,供客户端(例如 Discord 机器人、Web 应用程序等)监听。
  1. 在 lib.rs 底部添加实际的事件声明:
#[event]
pub struct WhaleTransferEvent {
    pub whale_address: Pubkey,
    pub transfer_amount: u64,
}

实现 fallback 指令

最后但同样重要的是,fallback 指令。在 transfer_hook 指令之后添加以下代码片段:

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

    // 匹配指令识别符以执行 transfer hook 接口指令
    // token2022 程序在代币转移时调用此指令
    match instruction {
        TransferHookInstruction::Execute { amount } => {
            let amount_bytes = amount.to_le_bytes();

            // 在我们的程序中调用自定义 transfer hook 指令
            __private::__global::transfer_hook(program_id, accounts, &amount_bytes)
        }
        _ => return Err(ProgramError::InvalidInstructionData.into()),
    }
}

fallback 指令取自 Solana 的 hello-world transfer hook 示例

虽然看起来很复杂,但这个指令其实相当简单:

  1. 解包指令数据并将其转换为 TransferHookInstruction
  2. 匹配 TransferHookInstruction 枚举。
  3. 如果是 Execute 指令,获取转移的金额并调用 transfer_hook 指令。
  4. 如果不是 Execute 指令,则返回无效指令数据的错误。

继续将程序部署到 Devnet 🎉

第 2 步:铸造

好的,我们的程序已经部署,现在我们可以将注意力转向创建一个实际使用它的代币铸造。

  1. 使用以下命令创建一个铸造:
spl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb create-token --transfer-hook <your transfer hook program id>

🧑‍🏫 提示

  • 注意命令中的程序 id?那是 Token 2022 程序的地址。我们必须指定它以便铸造实际使用新标准。
  • 为铸造注册 transfer hook 就像在 spl-token 命令中添加 --transfer-hook <address> 子命令一样简单。
  1. 为你配置的钱包创建代币账户:
spl-token create-account <the token address from previous step>
  1. 向你的钱包铸造一些代币:
spl-token mint <the token address from previous step> 3000

第 3 步:初始化 ExtraAccountMeta 账户

如果你尝试转移代币(例如使用 spl-token transfer 命令),你将遇到 AccountNotFound 错误。这是因为我们从未初始化我们的老朋友 ExtraAccountMetaList

我们在程序中创建了一个指令来初始化账户,initialize_extra_account,所以现在是调用它的最佳时机。

我创建了这个小的 TypeScript 代码来调用它,但你可以随意使用任何你喜欢的方法:

import { readFile } from "fs/promises";
import * as anchor from "@coral-xyz/anchor";
// 这两个文件是从构建的 Anchor 程序 Target/idl 和 Target/types 文件夹中复制的。
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);
});

💈 完整项目在 GitHub

第 4 步:让转移开始!

就是这样,朋友们!随着 transfer hook 程序的部署,配置使用它的铸造,以及 ExtraAccountMetaList 账户的初始化,我们终于可以开始使用我们的 hook 了:

transfer hook 正在运行!

结束语

  • 你可以在这里找到 whale transfer hook 程序的完整源码。
  • 你可以在这里找到示例监听器的源码。
  • 欢迎评论 :)

非常感谢 Sara Lumelsky 帮助我处理语法和其他问题 😅

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Johnny Tordgeman
Johnny Tordgeman
Senior Backend Engineer - Blockchains @FireblocksHQ | Loves to talk about Rust ? / TypeScript / JavaScript / WebAssembly | Web3 is AWESOME