Solana 代币 2022 — Transfer Hook
照片由 Fabian Blank 拍摄,发布在 Unsplash
Token 2022 计划引入了几项令人兴奋的扩展,增强了铸造和代币账户的功能。在这些功能中,我个人最喜欢的是Transfer Hook (转账钩子) 🪝。
让我们戴上想象的帽子,想象一下这个美好的场景:你是一个 NFT 项目的所有者。你向持有者发放代币,以奖励他们将 NFT 抵押给你。你将你的项目视为一个“封闭社区”,这意味着只有你的 NFT 持有者才能持有你的代币。在没有转账钩子之前,你必须构建自己的程序来处理转账,以强制执行这种行为。但有了转账钩子,这就非常简单了!你只需要构建一个转账钩子程序,验证转账的目标地址是否确实是白名单地址之一,如果不是,则停止交易。另一个例子?假设你希望用户在每次进行代币转账时支付额外费用……没问题!这也可以通过构建一个处理转账的钩子来实现!
现在,在 NFT 项目的背景下,这些例子可能看起来有些愚蠢,但想象一下更大的图景,这样的钩子可以帮助遵守监管要求,例如强制执行持有期或设置代币的最大持有量。
顾名思义,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
指令的作用。
在完成我们程序的高层设计后,让我们开始实际编写转账钩子程序 🎉。
anchor init transfer-hook-whale
cd programs/transfer-hook-whale
cargo add anchor-spl spl-transfer-hook-interface spl_tlv_account_resolution
idl-build
:[features]
...
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
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;
一个转账钩子程序通常由三个指令组成:
extra_account_meta_list
)的指令,该账户保存转账钩子可以使用的额外账户列表。execute
接口,并在匹配时调用我们的 transfer_hook
指令。#[account]
pub struct WhaleAccount {
pub whale_address: Pubkey,
pub transfer_amount: u64
}
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 的字节。这意味着我们铸造的所有代币将共享同一个账户。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(())
}
这里有很多内容,让我们逐步理解:
ExtraAccountMeta
账户的向量 (account_metas
),指定我们将使用的不同额外账户。这些可以从种子、公钥等指定。在我们的例子中,我们只需要一个额外账户——鲸鱼详细信息账户。extra_account_meta_list
PDA。create_account
CPI 实际创建 extra_account_meta_list
账户,提供所有需要的数据(付款人、签名者种子、账户大小等)。我们达到了程序的核心——transfer_hook
指令!这是实际操作发生的地方,因为每当进行交易时都会调用此指令。
InitializeExtraAccountMeta
下方添加以下内容:#[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
账户所需的额外账户。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(())
}
🦁 这里没有太多疯狂的事情:
WhaleTransferEvent
的事件,供客户端(例如 Discord 机器人、Web 应用程序等)监听。#[event]
pub struct WhaleTransferEvent {
pub whale_address: Pubkey,
pub transfer_amount: u64,
}
最后但同样重要的是,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 示例 。
虽然看起来很复杂,但这个指令其实相当简单:
TransferHookInstruction
。TransferHookInstruction
枚举。Execute
指令,获取转移的金额并调用 transfer_hook
指令。Execute
指令,则返回无效指令数据的错误。继续将程序部署到 Devnet 🎉
好的,我们的程序已经部署,现在我们可以将注意力转向创建一个实际使用它的代币铸造。
spl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb create-token --transfer-hook <your transfer hook program id>
🧑🏫 提示:
spl-token
命令中添加 --transfer-hook <address>
子命令一样简单。spl-token create-account <the token address from previous step>
spl-token mint <the token address from previous step> 3000
如果你尝试转移代币(例如使用 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
就是这样,朋友们!随着 transfer hook 程序的部署,配置使用它的铸造,以及 ExtraAccountMetaList
账户的初始化,我们终于可以开始使用我们的 hook 了:
transfer hook 正在运行!
非常感谢 Sara Lumelsky 帮助我处理语法和其他问题 😅
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!