跳转至主要内容

阅读时间 19 分钟

本指南提供 Solana Web3.js(旧版 v 1.x)和 Solana Kit(v 2.x)两个版本。请选择相应的选项卡,查看您所选库的代码片段和操作说明:

概述

发送 Solana 程序库(SPL)代币是 Solana 开发中的关键机制。无论您是向社区空投白名单代币、批量向其他钱包发送 NFT,还是管理托管账户之间的代币流转,都不可避免地需要能够转移 SPL 代币。转移 SPL 代币与发送 SOL 略有不同,因此在您的 Solana 开发过程中,了解其工作原理至关重要。

您将负责的工作内容

在本指南中,您将使用 TypeScript 编写一个脚本,用于在 Solana 开发网(devnet)上将一个 SPL 代币从一个账户转移到另一个账户。

如果您想了解如何在基于 Anchor 框架的程序中转账 SPL 代币和 SOL,请参阅我们的指南:《如何使用 Anchor 转账 SOL 和 SPL 代币》

更喜欢视频教程吗?跟着萨希尔一起学习如何在 Solana 开发网(Devnet)上转账 SPL 代币。
订阅我们的YouTube频道,观看更多视频!

您需要准备的物品

  • 已安装Node.js(20.0 及以上版本)
  • 具备 TypeScript 经验,并已安装ts-node

SPL 代币账户概述

在开始之前,了解 Solana 代币计划账户中的几个组成部分——铸造 ID 和关联代币账户——的工作原理会很有帮助。

Mint ID

每个 SPL 代币都有一个唯一的铸造 ID,可用于将其与其他类型的代币区分开来。 例如,$USDC SPL 代币的铸造 ID 为EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v,而 $SAMO 的铸造 ID 为7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU

值得注意的是,每个 NFT 都有一个唯一的铸造地址(这也是其不可替代性的部分原因)。例如, “Famous Fox #4679”的铸造 ID 为GS2AtoEoL9DgaYg9xC7cUumftB7vb2CPMrczZCKgJv1Y,而“Famous Fox #6562”的铸造 ID 为7FTdQdMqkk5Xc2oFsYR88BuJt2yyCPReTpqr3viH6b6C。 正如您稍后将看到的,这是这两个代币之间的一项重要区别。

如果您想进一步了解如何铸造 SPL 代币,请参阅我们的指南: 《如何使用新的 Metaplex 代币标准创建可互换的 SPL 代币》 以及 《如何在 Solana 上铸造 NFT》

关联代币账户

Solana 代币计划“根据用户的主系统账户地址和代币铸造地址生成一个代币账户密钥,从而允许用户为其持有的每种代币创建一个主代币账户”(来源:spl.solana.com)。该账户被称为关联代币账户(Associated Token Account,简称“ATA”)。实际上,ATA 是一个与用户及特定代币铸造相关联的唯一账户。 例如,假设我是账户“DEMO...1234”的所有者。我可以拥有多个 ATA——每个 ATA 对应我持有的一个代币铸造地址(例如,每个 $USDC、$SAMO 等都有一个唯一的 ATA,如下图所示)。

代币转账必须始终在两个关联同一铸造地址的 ATA 之间进行。这意味着我们无法将 $USDC 从我的 ATA 转账到你的 $SAMO ATA(参见下例)。

正如您稍后将在我们的代码中看到的,并非每个账户都已为每个铸币厂分配了 ATA。如果用户此前从未与某枚代币进行过交互,发送方必须“创建”该代币,并存入必要的租金,以保持该账户的活跃状态。

这个概念起初可能有些晦涩,但非常重要,所以请花点时间确保自己真正理解它!我发现通过在 Solana Explorer 的代币详情页面浏览我的钱包非常有帮助:https://explorer.solana.com/address/YOUR_WALLET_ADDRESS/tokens?display=detail。在那里你可以看到你拥有的每一枚 ATA、其账户 ID 以及相关的铸造 ID:

如果您想进一步了解 Solana SPL 代币计划库,请访问spl.solana.com

准备好开始编程了吗?我也是!

设置您的项目

在终端中使用以下命令创建一个新的项目目录:

mkdir spl-transfer-kit
cd spl-transfer-kit

为您的应用程序创建一个文件,命名为app.ts

echo > app.ts

使用“yes”标志初始化项目,以便为新包采用默认值:

yarn init --yes
#或者
npm init --yes

创建一个采用现代模块解析方式的tsconfig.json文件:

tsc --init 

请将tsconfig.json更新为以下内容:

{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"target": "ESNext"
}
}

安装 Solana Kit 的依赖项

在本练习中,我们需要添加 Solana Kit 和 Token 程序库。请在终端中输入:

yarn add @solana/kit @solana-program/token
#或者
npm install @solana/kit @solana-program/token

创建钱包并领取SOL空投

我们需要完成的第一项任务是创建一个钱包账户并为其充值。我们将使用下方的便捷工具自动生成一个新钱包,并向其中空投 1 SOL。

🔑使用 Devnet SOL生成一个新钱包

注意:过频地发送空投请求可能会触发 429(请求过多)错误。

成功生成密钥对后,你会发现有两个新的常量: 秘密 以及 FROM_KEYPAIR,一个密钥对。该 秘密 是一个 32 字节的数组,用于生成公钥和私钥。该 FROM_KEYPAIR 这是一个用于签署交易的 Keypair 实例(我们已空投了一些开发网 SOL 用于支付 gas 费用)。如果您尚未添加,请务必将其添加到代码中,放在其他常量之后。

在导入语句下方,粘贴您的新密钥数组:

const secret = [0,...,0]; // 👈 请替换为您的密钥

铸造或空投一些 SPL 代币

要完成本指南,您需要在 Devnet 上拥有一些 SPL 代币。获取 Devnet SPL 代币的方法有多种——以下列举了几种。

  1. 铸造您自己的可互换代币(指南:《如何使用新的 Metaplex 代币标准创建可互换的 SPL 代币》)。
  2. 使用 Candy Machine 铸造一个或多个 NFT(指南:如何使用 Sugar(Candy Machine)在 Solana 上部署 NFT 系列》)。
  3. SPL 代币水龙头申请 $DUMMY 代币空投。*
  4. 如果您已经在其他钱包中持有 Devnet SPL 代币,可以使用 Phantom 或其他钱包界面将其转出。*

在继续之前,您应该能够访问 Solana 浏览器,并确认您的钱包在开发网(https://explorer.solana.com/address/YOUR_WALLET_ADDRESS/tokens?cluster=devnet)上拥有 SOL 余额以及至少一种 SPL 代币。界面应大致如下所示:

让我们开始吧。

设置您的应用

导入必要的依赖项

打开app.ts 文件,并在第1 行粘贴以下导入语句:

import { 
createSolanaRpc,
createSolanaRpcSubscriptions,
address,
pipe,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstruction,
signTransactionMessageWithSigners,
sendAndConfirmTransactionFactory,
airdropFactory,
lamports,
getSignatureFromTransaction,
createKeyPairFromBytes,
createSignerFromKeyPair
} from '@solana/kit';
import {
TOKEN_PROGRAM_ADDRESS,
fetchToken,
getTransferInstruction,
findAssociatedTokenPda,
getCreateAssociatedTokenIdempotentInstruction
} from '@solana-program/token';

除了在上一步中创建的钱包外,我们还从 Solana Kit 和 Token 程序库中导入了必要的方法和类。

设置您的 Quicknode 端点

要在 Solana 上进行开发,您需要一个 API 端点来连接网络。您可以使用公共节点,也可以部署和管理自己的基础设施;不过,如果您希望响应速度提升 8 倍,不妨将繁重的工作交给我们处理。了解为何超过 50% 的 Solana 项目选择 Quicknode,并点击此处注册账户。我们将使用一个 Solana Devnet 节点。

复制 HTTP 提供程序的链接:

app.ts文件中,在 import 语句下方声明您的 RPC 并建立与 Solana 的连接:

const QUICKNODE_RPC = 'https://example.solana-devnet.quiknode.pro/0123456/';
const QUICKNODE_RPC_SUBSCRIPTIONS = 'wss://example.solana-devnet.quiknode.pro/0123456/';
const rpc = createSolanaRpc(QUICKNODE_RPC);
const rpcSubscriptions = createSolanaRpcSubscriptions(QUICKNODE_RPC_SUBSCRIPTIONS);
const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });

请务必替换 QUICKNODE_RPC 以及 QUICKNODE_RPC_SUBSCRIPTIONS 请填写您实际的 Quicknode 端点 URL(来自 Quicknode 控制台的 https 和 wss 链接)。

声明变量

要运行该脚本,你需要声明几个变量:目标账户(即你希望将代币转账给的账户所有者)、要转账的代币的铸造地址,以及要转账的代币数量。请在 RPC 配置下方添加以下声明:

const DESTINATION_WALLET = 地址('DemoKMZWkk483hX4mUrcJoo3zVvsKhm8XXs28TuwZw9H'); 
const MINT_ADDRESS = 地址('DoJuta7joTSuuoozqQtjtnASRYiVsT435gh4srh5LLGK'); //您必须修改此值!
const TRANSFER_AMOUNT = 1n; // 使用 BigInt 实现代币金额的高精度计算

替换 MINT_ADDRESS 并填写您计划发送的代币的铸造地址。如果您不知道代币的铸造地址,可以前往 https://explorer.solana.com/address/YOUR_WALLET_ADDRESS/tokens?cluster=devnet 然后点击 复制 图标以获取您的Mint地址。将其粘贴到 MINT_ADDRESS 变量。

注意:您还可以更新您的 DESTINATION_WALLET 地址和 TRANSFER_AMOUNT ,但本示例中这两个值均可使用。

获取小数位数

如果你之前创建过可互换的 SPL 代币,你就知道小数位数很重要。 由于区块链将代币供应量表示为整数值,我们必须根据代币元数据中分配的小数位数来转换代币数量(例如,如果铸造时设置了三位小数,则必须发送 1,000 个单位才能有效转移一个代币 [1,000 / 10**3])。

要获取小数部分,我们将调用rpc实例上的getAccountInfo方法,并解析 mint 账户数据。请在声明下方添加此函数:

async function getNumberDecimals(mintAddress) {

// getAccountInfo defaults to binary, using `jsonParsed` lets us read parsed.info.decimals without manual byte offsets.
const accountInfo = await rpc.getAccountInfo(mintAddress, { encoding: 'jsonParsed' }).send();

const data = accountInfo.value?.data;
if (!data || !('parsed' in data) || !data.parsed?.info) {
throw new Error('Failed to find mint account');
}

return (data.parsed.info as { decimals: number }).decimals;
}

严格来说,如果你知道代币铸造时的小数位数(可在 Solana Explorer 的代币页面上找到),其实并不需要这个。不过,我们还是想把它包含进来,因为这样你就可以针对不同的铸造情况修改代码,而无需更改其他变量。

让我们进入有趣的部分吧!

创建传递函数

创建一个新的异步函数sendTokens

async 函数 sendTokens() {
}

首先,我们需要根据密钥数组创建签名密钥对,并为其充值 SOL。在sendTokens 方法中,添加步骤 1:

    console.log(`Sending ${TRANSFER_AMOUNT} ${MINT_ADDRESS} to ${DESTINATION_WALLET}.`)

//Step 1
console.log(`1 - Creating keypair and funding with SOL`);
const keyPair = await createKeyPairFromBytes(new Uint8Array(secret));
const FROM_KEYPAIR = await createSignerFromKeyPair(keyPair);


// Fund the keypair with SOL for transaction fees
// (you can skip this step if you already have devnet SOL in your FROM_KEYPAIR)
await airdropFactory({ rpc, rpcSubscriptions })({
recipientAddress: FROM_KEYPAIR.address,
lamports: lamports(1_000_000_000n), // 1 SOL
commitment: 'confirmed',
});

console.log(` From wallet: ${FROM_KEYPAIR.address}`);

注意:在生产环境中,您应使用 generateKeyPairFromBytes(new Uint8Array(secret)) 请使用您实际的密钥,而不是生成一个新的密钥。

接下来,我们将为源账户和目标账户推导出关联代币账户(“ATA”)。请记住,每个账户都必须有一个ATA,用于将钱包与铸币厂关联起来;一个ATA只能持有来自单一铸币厂的代币;且发送方和接收方的关联代币账户所对应的铸币厂必须相同。添加步骤 2:

    //Step 2
console.log(`2 - Deriving Associated Token Accounts`);

const [sourceTokenAccount] = await findAssociatedTokenPda({
owner: FROM_KEYPAIR.address,
mint: MINT_ADDRESS,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});

const [destinationTokenAccount] = await findAssociatedTokenPda({
owner: DESTINATION_WALLET,
mint: MINT_ADDRESS,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});

console.log(` Source Token Account: ${sourceTokenAccount}`);
console.log(` Destination Token Account: ${destinationTokenAccount}`);

接下来,我们需要检查目标代币账户是否存在,并在必要时创建该账户。添加步骤 3:

    console.log(`3 - Checking Destination Token Account`);
let needsDestinationAccount = false;
try {
await rpc.getAccountInfo(destinationTokenAccount).send();
} catch (error) {
needsDestinationAccount = true;
}

if (needsDestinationAccount) {
console.log(` Destination token account does not exist, will create it`);
} else {
console.log(` Destination token account exists`);
}

为了正确计算金额,让我们获取小数位数。添加第 4 步:

    //Step 4
console.log(`4 - Fetching Number of Decimals for Mint: ${MINT_ADDRESS}`);
const numberDecimals = await getNumberDecimals(MINT_ADDRESS);
console.log(` Number of Decimals: ${numberDecimals}`);

const transferAmountWithDecimals = TRANSFER_AMOUNT * BigInt(Math.pow(10, numberDecimals));

最后,我们需要使用 Solana Kit 的函数式方法来组装并发送交易。添加第 5 步:

    //Step 5
console.log(`5 - Creating and Sending Transaction`);

const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();

const transactionMessage = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(FROM_KEYPAIR, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => {
// Add create ATA instruction if needed
if (needsDestinationAccount) {
return appendTransactionMessageInstruction(
getCreateAssociatedTokenIdempotentInstruction({
payer: FROM_KEYPAIR,
ata: destinationTokenAccount,
owner: DESTINATION_WALLET,
mint: MINT_ADDRESS,
}),
tx
);
}
return tx;
},
(tx) => appendTransactionMessageInstruction(
getTransferInstruction({
source: sourceTokenAccount,
destination: destinationTokenAccount,
authority: FROM_KEYPAIR,
amount: transferAmountWithDecimals,
}),
tx
)
);

const signedTransaction = await signTransactionMessageWithSigners(transactionMessage);
await sendAndConfirmTransaction(signedTransaction, { commitment: 'confirmed' });
const signature = await getSignatureFromTransaction(signedTransaction);

console.log(
'\x1b[32m', //Green Text
` Transaction Success!🎉`,
`\n https://explorer.solana.com/tx/${signature}?cluster=devnet`
);

我们在这里要做的是指示我们的应用,将TRANSFER_AMOUNT个代币(请注意,必须乘以 10 的numberDecimals 次方)从sourceTokenAccount转账至destinationTokenAccount

Solana Kit 的方法采用函数式编程风格,其中 管道 函数用于逐步构建事务。这提供了更好的类型安全性,并使事务构建过程更加明确。此外,如果目标 ATA 不存在,我们会在同一事务中使用幂等指令来创建它——即使该账户已经存在,该操作也不会失败。

别在意那个 console.log——那只是个小技巧,用来让成功提示以绿色文字显示。

🏃‍♂️ 运行你的代码

最后,在代码结尾处调用sendTokens

sendTokens().catch(控制台.error);

在终端中,输入以下命令运行你的代码:

ts-node app.ts

恭喜!如果您按照上述步骤操作,应该会看到类似以下的内容:

后续步骤与总结

太棒了!现在你已经知道如何将 SPL 代币从一个用户转给另一个用户了!想更进一步吗?以下是一些可以让你更深入探索的建议:

  1. 你能编写一个脚本,将你所有的 NFT 转移到另一个钱包吗?(提示:你可能需要先研究一下如何获取用户钱包中的所有代币)。

  2. 你能编写一个脚本,将 SPL 代币通过空投分发给一批钱包吗?这些钱包可能与你的铸造相关联,也可能无关。(提示:可参考我们关于批量分发 SOL 的指南以获取灵感)。

如果您愿意尝试一下,或者有任何问题想问我们,请告诉我们!欢迎在DiscordTwitter 上找到我们!

我们 ❤️ 您的反馈!

如果您有任何反馈或对新主题的建议,请告诉我们。我们非常期待您的来信。

分享本指南