本文介绍了如何使用Solana的SPL Token扩展来创建动态NFT。文章详细讲解了步骤,包括设置开发环境、创建具有自定义元数据字段的Solana Token、移除元数据字段、去除铸造权限和增加“points”元数据字段,并提供了代码示例和详细的解释。用户需要具备Solana和Web3钱包的基础知识,使用node.js和相关依赖进行项目开发。
Token Extensions(之前称为 Token 2022)为 Solana SPL 代币程序带来了令人兴奋的新功能和自定义选项。该程序包含了其前身的所有特性(它与原始 Token 指令和账户布局保持兼容),同时提供了新的指令和功能。在本指南中,我们将探索两个新的扩展,仅使用 SPL Token 程序来创建一个动态 NFT:
依赖项 | 版本 |
---|---|
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 |
让我们创建一个新的 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 端点(在 这里 注册一个账户)。除了创建我们必要的权限账户和定义我们的代币元数据外,我们还添加了两个辅助函数:
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
字段是一个键值对数组,可用于存储自定义元数据。我们添加了三个字段:
如果你希望使用你自己的元数据,可以用你自己的自定义元数据替换 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。让我们完成剩下的步骤。
首先,我们将创建 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);
}
接下来,让我们创建初始化铸造的交易。该交易需要做几件事:
⛔️ 如果这些值中的任何一个不正确,由于账户输入不正确,交易将失败。请确保仔细检查传入这些函数的参数。
在 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];
}
我们实际上是在向网络发送三个交易:
transaction
)最后,我们的函数返回铸造和初始化交易的签名。
哎呀!我们意外地向元数据中添加了一个我们不想要的字段。让我们将其移除。我们将创建一个名为 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”字段。然后,我们发送并确认交易并返回交易签名。
由于我们正在创建 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
以移除权限)。然后,我们发送并确认交易并返回交易签名。
最后,让我们在代币元数据中增加“分数”字段。我们将创建一个名为 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”:
注意:有时 NFT 数据不会立即呈现。如果是这种情况,请稍等片刻,然后尝试刷新页面。
干得不错!
如果你愿意,你可以在 QuickNode 示例 GitHub 仓库 中找到我们的完整代码。
你现在又多了一个在 Solana 上创建和管理 NFT 的工具。为了继续练习,挑战自己为你的 NFT 添加更多功能。你可以尝试:
NonTransferable
Extension 使 NFT 变得不可转让TransferHook
Extension 强制执行版权费我们希望了解你所构建的内容,以及你计划如何在项目中使用 Token Extensions。请在 Discord 上与我们联系,或在 Twitter 上关注我们,以及时了解所有最新信息!
让我们知道 如果你有什么反馈或对新主题的请求。我们很想听到你的意见。
- 原文链接: quicknode.com/guides/sol...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!