如何使用SolanaToken扩展收取转账费用

  • QuickNode
  • 发布于 2025-01-30 17:38
  • 阅读 25

本文介绍了Solana Labs团队新发布的Token-2022标准,通过引入转账费用扩展,为开发者提供了更灵活的token经济学控制。文章详细讲解了创建、铸造和转账token的步骤,包括如何收取和提取转账费用。内容涵盖了相关代码和配置,适合有一定Solana和区块链基础的开发者阅读。

概述

Token Exteions(又称 Token-2022)是 Solana Labs 团队新发布的一个原语。这个创新标准为你的代币经济引入了更多的控制能力。利用这些新工具,为了更具动态性地开发 Solana 做好准备。在本指南中,我们将探讨与 Token-2022 程序相关的最新功能,并学习如何使用新的转账费用扩展来创建、铸造和转移你的第一个代币。

你将要做的事情

在接下来的部分中,我们将逐步创建一个代币、对其进行铸造,并将其转移到另一个钱包。我们还将收取转账费用并将其提取到指定钱包:

  1. 使用 Token-2022 程序的费用转移扩展铸造代币
  2. 转移代币并收取转移费用
  3. 收获和提取收集的费用

你将需要的内容

依赖项 版本
node.js 18.12.1
tsc 5.0.2
ts-node 10.9.1
solana-cli 1.14.16

第一步 - 设置你的环境

让我们创建一个新的 Node.js 项目并安装 Solana-Web3.js 库。在你的终端中,按照顺序输入以下命令:

mkdir token-2022 && cd token-2022
npm init -y # 或 yarn init -y
npm install @solana/web3.js@1 @solana/spl-token # 或 yarn add @solana/web3.js@1 @solana/spl-token
echo > app.ts

在你喜欢的编辑器中打开 app.ts 文件并添加以下导入:

// 从 Solana web3.js 和 SPL Token 包导入必要的函数和常量
import {
    sendAndConfirmTransaction,
    Connection,
    Keypair,
    SystemProgram,
    Transaction,
    LAMPORTS_PER_SOL,
    Cluster,
    PublicKey,
    TransactionSignature,
    SignatureStatus,
    TransactionConfirmationStatus
} from '@solana/web3.js';

import {
    ExtensionType,
    createInitializeMintInstruction,
    mintTo,
    createAccount,
    getMintLen,
    getTransferFeeAmount,
    unpackAccount,
    TOKEN_2022_PROGRAM_ID,
    createInitializeTransferFeeConfigInstruction,
    harvestWithheldTokensToMint,
    transferCheckedWithFee,
    withdrawWithheldTokensFromAccounts,
    withdrawWithheldTokensFromMint,
    getOrCreateAssociatedTokenAccount,
    createAssociatedTokenAccountIdempotent
} from '@solana/spl-token';

// 初始化到本地 Solana 节点的连接
const connection = new Connection('http://127.0.0.1:8899', 'confirmed');

// 生成付款人、铸造权限和铸造密钥对的密钥
const payer = Keypair.generate();
const mintAuthority = Keypair.generate();
const mintKeypair = Keypair.generate();
const mint = mintKeypair.publicKey;

// 生成转账费用配置权限和提取权限的密钥
const transferFeeConfigAuthority = Keypair.generate();
const withdrawWithheldAuthority = Keypair.generate();

// 定义铸造所使用的扩展
const extensions = [\
    ExtensionType.TransferFeeConfig,\
];

// 计算铸造所需的长度
const mintLen = getMintLen(extensions);

// 设置小数位数、费用基点和最大费用
const decimals = 9;
const feeBasisPoints = 100; // 1%
const maxFee = BigInt(9 * Math.pow(10, decimals)); // 9 个代币

// 定义要铸造的数量和要转移的数量,并考虑小数位数
const mintAmount = BigInt(1_000_000 * Math.pow(10, decimals)); // 铸造 1,000,000 个代币
const transferAmount = BigInt(1_000 * Math.pow(10, decimals)); // 转移 1,000 个代币

// 计算转移的费用
const calcFee = (transferAmount * BigInt(feeBasisPoints)) / BigInt(10_000); // 期望 10 的费用
const fee = calcFee > maxFee ?  maxFee : calcFee; // 期望 9 的费用
// 辅助函数来生成 Explorer URL
function generateExplorerTxUrl(txId: string) {
    return `https://explorer.solana.com/tx/${txId}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`;
}

我们从 @solana/web3.js@solana/spl-token 导入必要的依赖项。我们将使用这些来创建、铸造和转移 Token-2022 代币。请注意,我们在这里建立了与本地集群的连接。如果你更愿意使用 devnet 或 mainnet,只需将连接 URL 更改为你的 QuickNode RPC 端点。除了创建我们所需的权限账户和计算预计的费用金额外,我们还添加了一个辅助函数来生成指向 Solana Explorer 上交易的 URL。这将对查看交易详情很有帮助。

这里有几个与 Token-2022 程序相关的新事物:

  1. transferFeeConfigAuthoritywithdrawWithheldAuthority 是新的权限,用于控制转账费用设置和提取已收集费用。我们将在后面的教程中使用它们。

  2. extensions 数组用于定义铸造所使用的扩展。Token-2022 程序支持 13 个扩展,但我们在此示例中只使用 TransferFeeConfig 扩展。extension 数组计算了我们的 Token-2022 账户(铸造账户不再都是 165 字节--它们的大小根据使用的扩展而有所不同)所需的空间。

  3. 我们还计算了预计的费用金额(我们将在后面的转移函数中使用)。因为我们必须在指令中包含费用金额(预计金额),以检查它在执行指令时是否与计算金额匹配。如果不匹配,交易将失败。这是为了帮助保护用户,以免在不完全了解费用金额的情况下错误地发送交易。

让我们定义一个辅助函数来确认交易。在你的 app.ts 文件中添加以下内容:

    async function confirmTransaction(
        connection: Connection,
        signature: TransactionSignature,
        desiredConfirmationStatus: TransactionConfirmationStatus = 'confirmed',
        timeout: number = 30000,
        pollInterval: number = 1000,
        searchTransactionHistory: boolean = false
    ): Promise<SignatureStatus> {
        const start = Date.now();

        while (Date.now() - start < timeout) {
            const { value: statuses } = await connection.getSignatureStatuses([signature], { searchTransactionHistory });

            if (!statuses || statuses.length === 0) {
                throw new Error('获取签名状态失败');
            }

            const status = statuses[0];

            if (status === null) {
                await new Promise(resolve => setTimeout(resolve, pollInterval));
                continue;
            }

            if (status.err) {
                throw new Error(`交易失败: ${JSON.stringify(status.err)}`);
            }

            if (status.confirmationStatus && status.confirmationStatus === desiredConfirmationStatus) {
                return status;
            }

            if (status.confirmationStatus === 'finalized') {
                return status;
            }

            await new Promise(resolve => setTimeout(resolve, pollInterval));
        }

        throw new Error(`交易确认超时,超时 ${timeout}ms`);
    }

最后,创建一个名为 main 的异步函数,并添加以下代码:

async function main() {
    // 第一步 - 向付款人空投
    const airdropSignature = await connection.requestAirdrop(payer.publicKey, 2 * LAMPORTS_PER_SOL);
    await confirmTransaction(connection, airdropSignature);

    // 第二步 - 创建新代币

    // 第三步 - 向所有者铸造代币

    // 第四步 - 从所有者发送代币到新账户

    // 第五步 - 获取费用账户

    // 第六步 - 收获费用
}
// 执行主函数
main();

我们概述了创建、铸造和转移 Token-2022 代币的步骤。我们已添加第一步:向我们的付款人账户空投一些 SOL。这是为了支付交易费用。

让我们构建其余的步骤。

第二步 - 创建新代币

让我们开始创建我们的新代币。

    // 第二步 - 创建新代币
    const mintLamports = await connection.getMinimumBalanceForRentExemption(mintLen);
    const mintTransaction = new Transaction().add(
        SystemProgram.createAccount({
            fromPubkey: payer.publicKey,
            newAccountPubkey: mint,
            space: mintLen,
            lamports: mintLamports,
            programId: TOKEN_2022_PROGRAM_ID,
        }),
        createInitializeTransferFeeConfigInstruction(
            mint,
            transferFeeConfigAuthority.publicKey,
            withdrawWithheldAuthority.publicKey,
            feeBasisPoints,
            maxFee,
            TOKEN_2022_PROGRAM_ID
        ),
        createInitializeMintInstruction(mint, decimals, mintAuthority.publicKey, null, TOKEN_2022_PROGRAM_ID)
    );
    const newTokenTx = await sendAndConfirmTransaction(connection, mintTransaction, [payer, mintKeypair], undefined);
    console.log("新代币已创建:", generateExplorerTxUrl(newTokenTx));

这一步与使用旧的 SPL 代币流程创建代币的过程相似,但有几个显著的不同之处:

  1. 首先,我们必须计算创建新代币所需的最低余额。这是通过将我们的 mintLen 传递给 getMinimumBalanceForRentExemption 函数来完成的。这将确保我们的代币铸造账户拥有足够的空间来容纳我们使用的扩展。
  2. 我们在事务中包含了一条新的指令来初始化转账费用配置。在这里我们定义:
    • feeBasisPoints - 对于此代币的转移,将收取的费用基点(1 个基点 = 0.01%)(交易金额的百分比,最高限额为 maxFee)。
    • maxFee - 对于此代币的转移,将收取的最大费用(绝对金额,而非百分比)。
    • transferFeeConfigAuthority - 用于控制转账费用设置的权限。
    • withdrawWithheldAuthority - 用于提取已收集费用的权限。
  3. 最后,请注意我们的交易指令使用 TOKEN_2022_PROGRAM_ID 作为程序 ID。这是 Token-2022 程序的程序 ID。如果你尝试使用旧的 SPL 代币程序 ID 执行此指令,交易将失败。

第三步 - 向所有者铸造代币

现在我们已经有了一个代币铸造,接下来让我们铸造一些代币!在你的 main() 函数中添加以下内容:

    // 第三步 - 向所有者铸造代币
    const owner = Keypair.generate();
    const sourceAccount = await createAssociatedTokenAccountIdempotent(connection, payer, mint, owner.publicKey, {}, TOKEN_2022_PROGRAM_ID);
    const mintSig = await mintTo(connection,payer,mint,sourceAccount,mintAuthority,mintAmount,[],undefined,TOKEN_2022_PROGRAM_ID);
    console.log("代币已铸造:", generateExplorerTxUrl(mintSig));

在这一部分中,我们将为代币所有者创建一个新的密钥对,并为我们的铸造设置一个源账户。createAssociatedTokenAccountIdempotent 函数用于初始化一个新的关联代币账户,以保存我们新创建的铸造中的代币(以 mintowner 和 Token-2022 程序 ID 为种子)。我们将使用此 sourceAccount 后续转移代币。

然后,使用 mintTo 函数,我们将代币铸造到我们的源账户中。我们记录返回的签名和用于查看 Solana Explorer 上交易的 URL。

同样,请注意我们在我们的 ata 和铸造交易中使用 TOKEN_2022_PROGRAM_ID 作为程序 ID。

第四步 - 从所有者发送代币到新账户

现在我们已经在源账户中有代币,让我们将它们发送到一个新账户。在你的 main() 函数中添加以下内容:

    // 第四步 - 从所有者发送代币到新账户
    const destinationOwner = Keypair.generate();
    const destinationAccount = await createAssociatedTokenAccountIdempotent(connection, payer, mint, destinationOwner.publicKey, {}, TOKEN_2022_PROGRAM_ID);
    const transferSig = await transferCheckedWithFee(
        connection,
        payer,
        sourceAccount,
        mint,
        destinationAccount,
        owner,
        transferAmount,
        decimals,
        fee,
        []
    );
    console.log("代币已转移:", generateExplorerTxUrl(transferSig));

与前一步类似,我们创建了一个新的钱包 destinationOwner,并创建了一个新的关联代币账户 destinationAccount,以定义我们的资金去向。然后我们调用了 Token-2022 新增的函数 transferCheckedWithFee,将代币从 sourceAccount 转移到 destinationAccount。此函数与旧版 SPL 代币的 transferChecked (需要 decimals 参数以 检查 客户端是否发送了预期代币数量)有相似之处。不同的是,我们现在还检查了预期的费用金额。这意味着我们必须在客户端计算预期的费用金额,并作为参数传递。在后端,费用参数将与我们在初始化转让费用配置时设置的 feeBasisPointsmaxFee 值进行检查。如果费用与预期不符,交易将失败。我们传递的所有参数如下:

  • connection - 连接到 Solana 集群。
  • payer - 付款人账户。
  • source - 源关联代币账户。
  • mint - 要转移的代币的铸造。
  • destination - 目标关联代币账户。
  • owner - 源关联代币账户的所有者。
  • amount - 要转移的代币数量(如果你还记得,这是我们希望发送的代币数量乘以 10^ decimals)。
  • decimals - 我们要转移的代币的小数位数。
  • fee - 转移的预期费用金额(如果你还记得,当我们计算此费用时,我们验证该值小于或等于 maxFee,否则我们使用 maxFee)。
  • 我们在此交易中不使用任何多重签名。

_注意:因为此交易是 Token-2022 中的本地交易,并且在 SPL 代币程序中不可用,TOKEN_2022_PROGRAM_ID 是默认值,并且不需要传递。_

我们还需要做一些工作,但如果你现在运行程序,你可以看到交易成功,代币已转移到新账户(扣除费用)。

在一个单独的终端中,你可以使用以下命令启动本地验证器:

solana-test-validator

在主终端中,运行你的脚本:

ts-node app.ts

你应该会看到类似以下内容的输出:

新代币已创建: https://explorer.solana.com/tx/TXID_1?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899
代币已铸造: https://explorer.solana.com/tx/TXID_2?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899
代币已转移: https://explorer.solana.com/tx/TXID_3?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899

如果你打开“代币转移”的 explorer URL,你应该会看到 1,000 个代币从源账户转移到目标账户,但只收到 991 个代币。余下的 9 个代币作为费用保留:

transfer

让我们找到这些代币!

第五步 - 获取费用账户

要提取从转移中收取的费用,我们必须找到持有这些费用的账户。这是一个两步过程:

  1. 获取所有与我们的代币铸造相关的代币账户。这看起来与 获取钱包持有的所有代币 非常相似,唯一的区别是我们使用的是 TOKEN_2022_PROGRAM_ID 而非 TOKEN_PROGRAM_ID。将以下 getProgramAccounts 查询添加到你的 main() 函数中:
    // 第五步 - 获取费用账户
    const allAccounts = await connection.getProgramAccounts(TOKEN_2022_PROGRAM_ID, {
        commitment: 'confirmed',
        filters: [\
            {\
                memcmp: {\
                    offset: 0,\
                    bytes: mint.toString(),\
                },\
            },\
        ],
    });

此请求查找由 Token-2022 程序拥有的账户,并根据我们感兴趣的铸造进行过滤(铸造在账户数据的 0 位置)。

  1. 解码每个账户以查看是否有任何费用可以提取。在你的 getProgramAccounts 查询后添加以下代码:
    const accountsToWithdrawFrom: PublicKey[] = [];
    for (const accountInfo of allAccounts) {
        const account = unpackAccount(accountInfo.pubkey, accountInfo.account, TOKEN_2022_PROGRAM_ID);
        const transferFeeAmount = getTransferFeeAmount(account);
        if (transferFeeAmount !== null && transferFeeAmount.withheldAmount > BigInt(0)) {
            accountsToWithdrawFrom.push(accountInfo.pubkey);
        }
    }

我们执行的操作如下:

  • 创建一个空数组 accountsToWithdrawFrom 以保存有费用可以提取的账户。
  • 循环遍历我们通过 getProgramAccounts 查询返回的每个账户。
  • 使用 unpackAccount 函数解锁账户数据,将其反序列化为 TokenAccount 对象。
  • 使用 getTransferFeeAmount 函数检查账户是否有任何可提取的费用。如果账户有可提取的费用,我们将其添加到 accountsToWithdrawFrom 数组中。

做得很好。现在让我们领取那些费用!

第六步 - 收获费用

关于费用,有一点重要的是要理解:它们在接收者账户汇聚,而不是一个集中费用库。这是为了最大化交易的并行化;否则,单个费用账户就需要在并行转账之间进行写锁定,从而降低协议的吞吐量。这也意味着,只有在所有费用被提取后,才能关闭一个代币账户。因此,有两种方法可以提取费用:一种是由 withdrawWithheldAuthority 触发(使用 withdrawWithheldTokensFromAccounts 方法),另一种是由账户持有者触发(使用 harvestWithheldTokensToMint 方法)。

让我们探索这两种方法:

  • 由权限提取
  • 由所有者提取
    // 第六步 - 通过权限提取费用
    const feeVault = Keypair.generate();
    const feeVaultAccount = await createAssociatedTokenAccountIdempotent(connection, payer, mint, feeVault.publicKey, {}, TOKEN_2022_PROGRAM_ID);

    const withdrawSig1 = await withdrawWithheldTokensFromAccounts(
        connection,
        payer,
        mint,
        feeVaultAccount,
        withdrawWithheldAuthority,
        [],
        accountsToWithdrawFrom
    );
    console.log("从账户提取:", generateExplorerTxUrl(withdrawSig1));
    // 第六步 - 由所有者提取费用
    const feeVault = Keypair.generate();
    const feeVaultAccount = await createAssociatedTokenAccountIdempotent(connection, payer, mint, feeVault.publicKey, {}, TOKEN_2022_PROGRAM_ID);

    const harvestSig = await harvestWithheldTokensToMint(connection, payer, mint, [destinationAccount]);
    console.log("由所有者收获:", generateExplorerTxUrl(harvestSig));

    // 已收获到铸造的代币可以由权限提取
    const withdrawSig2 = await withdrawWithheldTokensFromMint(
        connection,
        payer,
        mint,
        feeVaultAccount,
        withdrawWithheldAuthority,
        []
    );
    console.log("从铸造中提取:", generateExplorerTxUrl(withdrawSig2));

在这两种情况下,我们创建一个新的钱包 feeVault(和关联代币账户 feeVaultAccount),以保存我们要提取的费用。

通过权限提取费用
我们使用 withdrawWithheldTokensFromAccounts 方法从步骤 5 中找到的账户提取费用。此方法要求权限签名交易,因此我们传递 withdrawWithheldAuthority 钱包。

由所有者提取费用

  • 首先,我们使用 harvestWithheldTokensToMint 方法从接收账户收获费用到铸造。这要求所有者签名交易,因此我们传递 destinationAccount 钱包。在实际应用中,执行此操作的主要原因是用户(或程序)正在关闭该账户,需在关闭前提取所有费用。
  • 一旦费用移动到与铸造账户相关联,权限即可使用 withdrawWithheldTokensFromMint 方法提取它们。此方法要求权限签名交易,因此我们传递 withdrawWithheldAuthority 钱包。这与第一种情况略有不同,在第一种情况中,权限是从接收账户提取费用,而在这种情况下,我们是从铸造账户中提取。

我们建议尝试这两个示例,并使用打印到控制台的链接查看 Solana Explorer 上的交易。

运行代码

最后,在一个单独的终端中运行以下命令以启动本地 Solana 集群:

solana-test-validator

在主终端中,运行你的脚本:

ts-node app.ts

你应该会看到类似以下内容的输出:

新代币已创建: https://explorer.solana.com/tx/TXID_1?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899
代币已铸造: https://explorer.solana.com/tx/TXID_2?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899
代币已转移: https://explorer.solana.com/tx/TXID_3?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899
从账户提取: https://explorer.solana.com/tx/TXID_4?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899

同样,使用打印到控制台的链接探索 Solana Explorer 上的交易,这将帮助你理解 Token-2022 程序中费用的流动。

干得很好!

总结

你刚刚创建了一种新代币,铸造了一些代币,转移了代币,并从接收账户中提取费用。你现在可以将此代码作为自己项目的起点!如果你在开始 Token-2022 之旅时要记住一件事,那就是确保你的程序使用正确的程序 ID。

我们很想了解你正在构建的内容,以及你计划如何在项目中使用 Token-2022。请在 Discord 中给我们留言,或者在 Twitter 上关注我们,以便及时获悉最新信息!

我们 ❤️ 反馈!

告诉我们 如果你有任何反馈或对新主题的请求。我们期待你的意见。

  • 原文链接: quicknode.com/guides/sol...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
QuickNode
QuickNode
江湖只有他的大名,没有他的介绍。