Solana - 如何使用SPL元数据Token扩展创建Solana NFT - Quicknode

  • QuickNode
  • 发布于 2025-01-30 21:17
  • 阅读 20

本文介绍了如何使用Solana的SPL Token扩展来创建动态NFT。文章详细讲解了步骤,包括设置开发环境、创建具有自定义元数据字段的Solana Token、移除元数据字段、去除铸造权限和增加“points”元数据字段,并提供了代码示例和详细的解释。用户需要具备Solana和Web3钱包的基础知识,使用node.js和相关依赖进行项目开发。

概览

Token Extensions(之前称为 Token 2022)为 Solana SPL 代币程序带来了令人兴奋的新功能和自定义选项。该程序包含了其前身的所有特性(它与原始 Token 指令和账户布局保持兼容),同时提供了新的指令和功能。在本指南中,我们将探索两个新的扩展,仅使用 SPL Token 程序来创建一个动态 NFT:

  • 元数据,和
  • 元数据指针

你将做什么

  1. 铸造一个具有自定义元数据字段的 Solana 代币,包括一个“分数”字段
  2. 添加/移除自定义元数据字段
  3. 移除铸造权限
  4. 增加代币的“分数”元数据字段

你需要什么

依赖项 版本
node.js 20.9.0
tsc 5.0.2
ts-node 10.9.1
@solana/web3.js ^1.87.6
@solana/spl-token ^0.3.11
@solana/spl-token-metadata ^0.1.2

步骤 1 - 设置你的环境

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

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

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

// 从 Solana web3.js 和 SPL Token 包中导入所需的函数和常量
import {
    Connection,
    Keypair,
    SystemProgram,
    Transaction,
    LAMPORTS_PER_SOL,
    sendAndConfirmTransaction,
    TransactionSignature,
    SignatureStatus,
    TransactionConfirmationStatus
} from '@solana/web3.js';
import {
    TOKEN_2022_PROGRAM_ID,
    createInitializeMintInstruction,
    mintTo,
    createAssociatedTokenAccountIdempotent,
    AuthorityType,
    createInitializeMetadataPointerInstruction,
    TYPE_SIZE,
    LENGTH_SIZE,
    getMintLen,
    ExtensionType,
    getMint,
    getMetadataPointerState,
    getTokenMetadata,
    createSetAuthorityInstruction,
} from '@solana/spl-token';
import {
    createInitializeInstruction,
    createUpdateFieldInstruction,
    createRemoveKeyInstruction,
    pack,
    TokenMetadata,
} from '@solana/spl-token-metadata';

我们从 @solana/web3.js@solana/spl-token@solana/spl-token-metadata 库中导入所需的依赖项。我们将使用这些库来创建、铸造和获取具有扩展的 SPL 代币的数据。这里有几个新项(尤其是 spl-token-metadata 包);我们将随着程序的进行进一步探索。

接下来,我们要建立与本地集群的连接。如果你倾向于使用 devnet 或 mainnet,你可能需要进行一些小的重构(例如,下面的 airdrop 函数在 mainnet 上将不可用,并可能在 devnet 上受限于速率)。你还需要将连接 URL 更改为你的 QuickNode RPC 端点(在 这里 注册一个账户)。除了创建我们必要的权限账户和定义我们的代币元数据外,我们还添加了两个辅助函数:

  1. 一个生成指向 Solana FM 上事务的 URL(目前支持 Token Extension Metadata)。这将对查看交易详细信息很有用。
  2. 一个向我们的支付账户进行 airdrop 的函数。这对于支付交易费用和新账户租金是必要的。
const connection = new Connection('http://127.0.0.1:8899', 'confirmed');

const payer = Keypair.generate();
const authority = Keypair.generate();
const owner = Keypair.generate();
const mintKeypair = Keypair.generate();
const mint = mintKeypair.publicKey;

const tokenMetadata: TokenMetadata = {
    updateAuthority: authority.publicKey,
    mint: mint,
    name: 'QN Pixel',
    symbol: 'QNPIX',
    uri: "https://qn-shared.quicknode-ipfs.com/ipfs/QmQFh6WuQaWAMLsw9paLZYvTsdL5xJESzcoSxzb6ZU3Gjx",
    additionalMetadata: [["Background", "Blue"], ["WrongData", "DeleteMe!"], ["Points", "0"]],
};

const decimals = 0;
const mintAmount = 1;

function generateExplorerUrl(identifier: string, isAddress: boolean = false): string {
    if (!identifier) return '';
    const baseUrl = 'https://solana.fm';
    const localSuffix = '?cluster=localnet-solana';
    const slug = isAddress ? 'address' : 'tx';
    return `${baseUrl}/${slug}/${identifier}${localSuffix}`;
}

async function airdropLamports() {
    const airdropSignature = await connection.requestAirdrop(payer.publicKey, 2 * LAMPORTS_PER_SOL);
    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`);
    }

    await confirmTransaction(connection, airdropSignature);
}

注意我们定义了一个 tokenMetadata 对象,具有以下在 TokenMetadata 接口 中指定的字段:

export interface TokenMetadata {
    // 可以签名以更新元数据的权限
    updateAuthority?: PublicKey;
    // 其关联的铸币,用于抵消伪造以确保元数据属于特定铸造
    mint: PublicKey;
    // 代币的较长名称
    name: string;
    // 代币的简短符号
    symbol: string;
    // 指向丰富元数据的 URI
    uri: string;
    // 有关代币的任何其他元数据,作为键值对
    additionalMetadata: [string, string][];
}

additionalMetadata 字段是一个键值对数组,可用于存储自定义元数据。我们添加了三个字段:

  • “Background”,一个静态字段
  • “WrongData”,我们希望移除的字段(出于演示的目的)
  • “Points”,我们稍后将修改的字段

如果你希望使用你自己的元数据,可以用你自己的自定义元数据替换 tokenMetadata 对象。要将 .json 和图像文件上传到 IPFS,可以使用 QuickNode IPFS Gateway

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

async function main() {
    try {
        await airdropLamports();

        // 1. 创建代币和铸造
        const [initSig, mintSig] = await createTokenAndMint();
        console.log(`代币创建并铸造:`);
        console.log(`   ${generateExplorerUrl(initSig)}`);
        console.log(`   ${generateExplorerUrl(mintSig)}`);

        // 2. 移除元数据字段
        const cleanMetaTxId = await removeMetadataField();
        console.log(`元数据字段已移除:`);
        console.log(`   ${generateExplorerUrl(cleanMetaTxId)}`);

        // 3. 移除权限
        const removeAuthTxId = await removeTokenAuthority();
        console.log(`权限已移除:`);
        console.log(`   ${generateExplorerUrl(removeAuthTxId)}`);

        // 4. 增加分数
        const incrementPointsTxId = await incrementPoints(10);
        console.log(`分数已增加:`);
        console.log(`   ${generateExplorerUrl(incrementPointsTxId)}`);

        // 记录新 NFT
        console.log(`新 NFT:`);
        console.log(`   ${generateExplorerUrl(mint.toBase58(), true)}`);

    } catch (err) {
        console.error(err);
    }
}

我们概述了创建、铸造和操作 NFT 的步骤。我们已经添加了第一步:向我们的支付账户进行 SOL airdrop。让我们完成剩下的步骤。

步骤 1 - 创建新代币

首先,我们将创建 createTokenAndMint 函数。该函数将创建一个具有我们指定扩展的新代币,并铸造到先前生成的 owner 钱包的新的关联代币账户中。将以下代码添加到你的 app.ts 文件:

async function createTokenAndMint(): Promise<[string, string]> {

}

我们将返回一个 Promise,包含两个字符串:代币初始化和代币铸造的交易签名。

要创建我们的铸造账户,我们需要确定铸造账户需要多少空间。我们可以使用 getMintLen 函数来计算铸造账户的最低余额。getMintLen 目前尚不支持具有可变大小的扩展(例如,Metadata),因此我们必须通过将铸造账户的大小和元数据的大小相加,手动计算铸造账户的最低余额。我们可以使用 getMinimumBalanceForRentExemption 函数来计算铸造账户的最低余额。将以下代码添加到 createTokenAndMint 函数中:

async function createTokenAndMint(): Promise<[string, string]> {
    // 计算铸造账户的最低余额
    const mintLen = getMintLen([ExtensionType.MetadataPointer]);
    const metadataLen = TYPE_SIZE + LENGTH_SIZE + pack(tokenMetadata).length;
    const mintLamports = await connection.getMinimumBalanceForRentExemption(mintLen + metadataLen);
}

接下来,让我们创建初始化铸造的交易。该交易需要做几件事:

  • 创建铸造账户
  • 将账户初始化为 Metadata Pointer 账户,这将允许我们的铸造账户指定其对应的元数据账户的地址(在我们的情况下,我们将元数据写入铸造账户本身并指向它)
  • 将账户初始化为 Mint 账户
  • 用代币元数据初始化账户
  • 使用自定义元数据字段更新代币元数据

⛔️ 如果这些值中的任何一个不正确,由于账户输入不正确,交易将失败。请确保仔细检查传入这些函数的参数。

mintLamports 声明之后,将以下代码添加到你的 createTokenAndMint 函数中:

async function createTokenAndMint(): Promise<[string, string]> {

    // 计算铸造账户的最低余额后...

    // 准备交易
    const transaction = new Transaction().add(
        SystemProgram.createAccount({
            fromPubkey: payer.publicKey,
            newAccountPubkey: mint,
            space: mintLen,
            lamports: mintLamports,
            programId: TOKEN_2022_PROGRAM_ID,
        }),
        createInitializeMetadataPointerInstruction(
            mint,
            authority.publicKey,
            mint,
            TOKEN_2022_PROGRAM_ID,
        ),
        createInitializeMintInstruction(
            mint,
            decimals,
            authority.publicKey,
            null,
            TOKEN_2022_PROGRAM_ID,
        ),
        createInitializeInstruction({
            programId: TOKEN_2022_PROGRAM_ID,
            metadata: mint,
            updateAuthority: authority.publicKey,
            mint: mint,
            mintAuthority: authority.publicKey,
            name: tokenMetadata.name,
            symbol: tokenMetadata.symbol,
            uri: tokenMetadata.uri,
        }),
        createUpdateFieldInstruction({
            programId: TOKEN_2022_PROGRAM_ID,
            metadata: mint,
            updateAuthority: authority.publicKey,
            field: tokenMetadata.additionalMetadata[0][0],
            value: tokenMetadata.additionalMetadata[0][1],
        }),
        createUpdateFieldInstruction({
            programId: TOKEN_2022_PROGRAM_ID,
            metadata: mint,
            updateAuthority: authority.publicKey,
            field: tokenMetadata.additionalMetadata[1][0],
            value: tokenMetadata.additionalMetadata[1][1],
        }),
        createUpdateFieldInstruction({
            programId: TOKEN_2022_PROGRAM_ID,
            metadata: mint,
            updateAuthority: authority.publicKey,
            field: tokenMetadata.additionalMetadata[2][0],
            value: tokenMetadata.additionalMetadata[2][1],
        }),

    );

}

我们在这里所做的就是创建一个新交易,并使用 .add() 方法将一系列指令附加到它。我们使用来自 @solana/spl-token@solana/spl-token-metadata 库的辅助函数来创建所需的指令:

  • SystemProgram.createAccount 用于创建具有所需空间和 lamports 的新账户
  • createInitializeMetadataPointerInstruction 函数将铸造账户初始化为元数据指针
  • createInitializeMintInstruction 函数用于初始化铸造账户
  • createInitializeInstruction 函数将铸造账户与代币元数据初始化
  • createUpdateFieldInstruction 函数用于使用自定义元数据字段更新代币元数据(注意,我们必须单独添加每个自定义字段)

在传递这些函数的参数时要非常小心。你可能想要检查这些函数的 TypeScript 定义,以确保传递正确的参数。你可以通过右键单击函数并选择“转到定义”在编辑器中找到定义。

代币程序 ID

提醒:使用 Token Extensions 时,必须将 TOKEN_2022_PROGRAM_ID 作为 programId 传递给每个指令。

最后,我们将发送并确认该交易。将以下代码添加到你的 createTokenAndMint 函数中 transaction 定义之后:

async function createTokenAndMint(): Promise<[string, string]> {

    // 计算铸造账户的最低余额...
    // 准备交易...

    // 使用元数据初始化 NFT
    const initSig = await sendAndConfirmTransaction(connection, transaction, [payer, mintKeypair, authority]);
    // 创建关联代币账户
    const sourceAccount = await createAssociatedTokenAccountIdempotent(connection, payer, mint, owner.publicKey, {}, TOKEN_2022_PROGRAM_ID);
    // 将 NFT 铸造到关联代币账户
    const mintSig = await mintTo(connection, payer, mint, sourceAccount, authority, mintAmount, [], undefined, TOKEN_2022_PROGRAM_ID);

    return [initSig, mintSig];
}

我们实际上是在向网络发送三个交易:

  1. 第一个交易使用元数据和自定义元数据字段初始化铸造账户(使用我们在上一步创建的 transaction
  2. 第二个交易为 NFT 的所有者创建一个关联代币账户
  3. 第三个交易将 NFT 铸造到关联代币账户

最后,我们的函数返回铸造和初始化交易的签名。

步骤 2 - 移除元数据字段

哎呀!我们意外地向元数据中添加了一个我们不想要的字段。让我们将其移除。我们将创建一个名为 removeMetadataField的新函数,以从我们的代币元数据中移除“WrongData”字段。将以下代码添加到你的 app.ts 文件中:

async function removeMetadataField() {
    const transaction = new Transaction().add(
        createRemoveKeyInstruction({
            programId: TOKEN_2022_PROGRAM_ID,
            metadata: mint,
            updateAuthority: authority.publicKey,
            key: 'WrongData',
            idempotent: true,
        })
    );
    const signature = await sendAndConfirmTransaction(connection, transaction, [payer, authority]);
    return signature;
}

@solana/spl-token-metadata 库提供了一个 createRemoveKeyInstruction 函数,用于创建一个指令,以从代币元数据中删除一个键。我们只需创建一个新交易,并添加指令以从代币元数据中移除“WrongData”字段。然后,我们发送并确认交易并返回交易签名。

步骤 3 - 移除权限

由于我们正在创建 NFT,我们希望将供应限制为 1 并防止任何进一步的铸造。我们可以通过移除铸造权限实现这一点。我们将创建一个名为 removeTokenAuthority 的新函数,以从铸造账户中移除铸造权限。将以下代码添加到你的 app.ts 文件中:

async function removeTokenAuthority(): Promise<string> {
    const transaction = new Transaction().add(
        createSetAuthorityInstruction(
            mint,
            authority.publicKey,
            AuthorityType.MintTokens,
            null,
            [],
            TOKEN_2022_PROGRAM_ID
        )
    );
    return await sendAndConfirmTransaction(connection, transaction, [payer, authority]);
}

我们使用 @solana/spl-token 库中的 createSetAuthorityInstruction 函数创建一个指令,将铸造账户的铸造权限设置为新的权限(在这种情况下,我们传入 null 以移除权限)。然后,我们发送并确认交易并返回交易签名。

步骤 4 - 增加分数

最后,让我们在代币元数据中增加“分数”字段。我们将创建一个名为 incrementPoints 的新函数,以指定的数额增加“分数”字段。将以下代码添加到你的 app.ts 文件中:

async function incrementPoints(pointsToAdd: number = 1) {
    // 获取铸造信息
    const mintInfo = await getMint(
        connection,
        mint,
        "confirmed",
        TOKEN_2022_PROGRAM_ID,
    );

    const metadataPointer = getMetadataPointerState(mintInfo);

    if (!metadataPointer || !metadataPointer.metadataAddress) {
        throw new Error('未找到元数据指针');
    }

    const metadata = await getTokenMetadata(
        connection,
        metadataPointer?.metadataAddress,
    );

    if (!metadata) {
        throw new Error('未找到元数据');
    }
    if (metadata.mint.toBase58() !== mint.toBase58()) {
        throw new Error('元数据与铸造不匹配');
    }
    const [key, currentPoints] = metadata.additionalMetadata.find(([key, _]) => key === 'Points') ?? [];
    let pointsAsNumber = parseInt(currentPoints ?? '0');
    pointsAsNumber += pointsToAdd;
    const transaction = new Transaction().add(
        createUpdateFieldInstruction({
            programId: TOKEN_2022_PROGRAM_ID,
            metadata: mint,
            updateAuthority: authority.publicKey,
            field: 'Points',
            value: pointsAsNumber.toString(),
        })
    );
    return await sendAndConfirmTransaction(connection, transaction, [payer, authority]);
}

与仅使用 createUpdateFieldInstruction 函数更新“分数”字段不同,我们在这里添加了一些检查,以使你对 SPL 代币的账户获取函数有一些熟悉度。

  • 首先,我们使用 getMint 获取铸造账户信息
  • 然后我们使用 getMetadataPointerState 获取元数据指针状态(这包括我们的元数据地址)
  • 我们使用 getTokenMetadata 获取铸造账户的元数据
  • 然后检查元数据是否与铸造账户匹配
  • 由于这个函数是一个分数增加器,我们还确保“分数”字段存在
  • 最后,我们增加分数并发送确认交易

自定义元数据字段

正如我们在处理分数时所看到的,自定义元数据字段全部存储为字符串。这意味着,如果你想存储一个数字,你必须在必要时在字符串和数字类型之间进行转换。

运行代码

在你的 app.ts 文件的末尾调用你的 main 函数:

main();

最后,在一个单独的终端中,运行以下命令以启动一个本地 Solana 集群(如果你使用的是其他集群,请跳过此步骤):

solana-test-validator

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

ts-node app.ts

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

代币创建并铸造:
   https://solana.fm/tx/2HRXAjCt2zuyH3XPD4kKcKvyyqDZ6B98vk6CB8UFNSWhgydR8CNRNqAjNQLnorPhmabszLUgY6Uh5crhSUFYGvB9?cluster=localnet-solana
   https://solana.fm/tx/37ngRFFSunK88gZKgTfsc866hqb5e3zXj4z3oeWxAdtsokPpcrwXb56sZwnJ7pS3X7HNbcBCtaaEgWKmDMmGgCks?cluster=localnet-solana
元数据字段已移除:
   https://solana.fm/tx/4TWGd9fDCCTyv39o35BtZyP9kyoYruFCe3XASvmXUpSZpgFGM3So238oQiQAMS14uhxKTDe48V5wujBtzDQFxEuE?cluster=localnet-solana
权限已移除:
   https://solana.fm/tx/Atbt1zwyRmBPijtC423yHTdWaM7ZZqiUoDuMpV5HHT9uHMDqyF4b4Mi4mQP6h9eEie75mew6RfmtbynGMWxyuXb?cluster=localnet-solana
分数已增加:
   https://solana.fm/tx/4CbUcLk7TPvydRghXgNTR8WpGJU82zeMpJh4ughdYWhbj7BE2krXhhStEu7FzjJzW3GDRfigAWnuEBUjS5XESqBC?cluster=localnet-solana
新 NFT:
   https://solana.fm/address/FPyoBNZ24Xv3yu9p5uqsgHXxMXkPMPHGF9pLAWRLFmWF?cluster=localnet-solana

你应该能够跟随最后的链接到 Solana FM 上你的 NFT 的页面。你应该看到 NFT 的图像、元数据(没有“WrongData”字段)、没有铸造权限,并且分数值为“10”:

Solana FM NFT

Solana FM NFT

注意:有时 NFT 数据不会立即呈现。如果是这种情况,请稍等片刻,然后尝试刷新页面。

干得不错!

总结

如果你愿意,你可以在 QuickNode 示例 GitHub 仓库 中找到我们的完整代码。

你现在又多了一个在 Solana 上创建和管理 NFT 的工具。为了继续练习,挑战自己为你的 NFT 添加更多功能。你可以尝试:

我们希望了解你所构建的内容,以及你计划如何在项目中使用 Token Extensions。请在 Discord 上与我们联系,或在 Twitter 上关注我们,以及时了解所有最新信息!

我们 ❤️ 反馈!

让我们知道 如果你有什么反馈或对新主题的请求。我们很想听到你的意见。

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

0 条评论

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