发送 Solana 程序库 (SPL) 代币是 Solana 开发中的关键机制。无论是向社区空投白名单代币、批量发送 NFT 到另一个钱包,还是管理托管账户间的代币流动,您最终都需要能够转移 SPL 代币。转移 SPL 代币与发送 SOL 略有不同,因此在您的 Solana 开发之旅中,理解其工作原理至关重要。
SPL 代币账户概述
在开始之前,了解 Solana 代币程序账户的两个组件如何工作会很有帮助:铸造 ID (Mint IDs) 和关联代币账户 (Associated Token Accounts)。
理解 Mint IDs
每个 SPL 代币都有一个唯一的 Mint ID,可以将其与任何其他类型的代币区分开来。值得注意的是,每个 NFT 也有一个唯一的铸造地址(这在一定程度上使其不可替代)。
理解关联代币账户 (ATA)
Solana 代币程序“从用户的主系统账户地址和代币铸造地址派生出一个代币账户密钥,允许用户为他们拥有的每个代币创建一个主代币账户”。该账户被称为关联代币账户或“ATA”。
实际上,ATA 是一个与特定用户和特定代币铸造相关联的唯一账户。代币转账必须始终在同一铸造地址关联的两个 ATA 之间进行。这意味着我们不能将 $USDC 从我的 ATA 发送到您的 $SAMO ATA。
如果用户之前未曾与某个代币交互过,发送方必须“创建”其 ATA 并存入必要的租金以使账户保持活跃。
项目设置与技术准备
所需条件
- 安装 Nodejs (版本 20.0 或更高)
- 具备 TypeScript 经验并已安装 ts-node
初始化项目
首先在终端中创建一个新的项目目录:
mkdir spl-transfer-kit
cd spl-transfer-kit为您的应用创建一个文件 app.ts,并使用默认值初始化项目:
yarn init --yes
# 或
npm init --yes创建一个具有现代模块解析的 tsconfig.json,并更新其内容为:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"target": "ESNext"
}
}安装依赖
我们将需要添加 Solana Kit 和代币程序库。在终端中输入:
yarn add @solana/kit @solana-program/token
# 或
npm install @solana/kit @solana-program/token获取测试网代币
要完成本指南,您需要在 Devnet 上拥有一些 SPL 代币。有几种方法可以获取:
- 铸造您自己的同质化代币。
- 使用 Candy Machine 铸造一个或多个 NFT。
- 从 SPL 代币水龙头请求 $DUMMY 代币空投。
- 如果您在另一个钱包中已经拥有 Devnet SPL 代币,可以使用 Phantom 或其他钱包界面发送它们。
在继续之前,您应该能够在 Solana Explorer 上看到您的钱包在 devnet 上有 SOL 余额和至少一个 SPL 代币。
编写核心转账脚本
导入必要依赖
打开 app.ts,并在第一行粘贴以下导入语句:
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';配置网络连接
在导入语句下,声明您的 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 替换为您实际的端点 URL。
声明关键变量
您需要声明一些变量来运行脚本:目标账户(您希望将代币转移到的所有者)、您要转移的代币的铸造地址以及要转移的代币数量。
const DESTINATION_WALLET = address('DemoKMZWkk483hX4mUrcJoo3zVvsKhm8XXs28TuwZw9H');
const MINT_ADDRESS = address('DoJuta7joTSuuoozqQtjtnASRYiVsT435gh4srh5LLGK'); //必须更改此值!
const TRANSFER_AMOUNT = 1n; // 使用 BigInt 表示精确的代币数量将 MINT_ADDRESS 替换为您计划发送的代币的铸造地址。
获取代币精度
由于链上将代币供应量表示为整数值,我们必须根据代币元数据中分配的精度位数来转换代币金额。
添加以下函数来获取精度:
async function getNumberDecimals(mintAddress) {
const accountInfo = await rpc.getAccountInfo(mintAddress, { encoding: 'base64' }).send();
if (!accountInfo.value?.data) {
throw new Error('Failed to find mint account');
}
// 解析铸造账户数据 - 精度在字节偏移量 44
const data = new Uint8Array(Buffer.from(accountInfo.value.data[0], 'base64'));
const decimals = data[44];
return decimals;
}实现代币转账功能
创建一个新的异步函数 sendTokens 来处理核心转账逻辑。
创建签名者并注入资金
async function sendTokens() {
console.log(`Sending ${TRANSFER_AMOUNT} ${MINT_ADDRESS} to ${DESTINATION_WALLET}.`);
// 步骤 1: 创建密钥对并注入 SOL
console.log(`1 - Creating keypair and funding with SOL`);
const keyPair = await createKeyPairFromBytes(new Uint8Array(secret));
const FROM_KEYPAIR = await createSignerFromKeyPair(keyPair);
// 为密钥对注入 SOL 以支付交易费用(如果您的 FROM_KEYPAIR 中已有 devnet SOL,可跳过此步)
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}`);推导关联代币账户 (ATA)
// 步骤 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}`);检查并创建目标 ATA
// 步骤 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: 获取铸造精度
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));构建并发送交易
// 步骤 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) => {
// 如果需要,添加创建 ATA 的指令
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', //绿色文本
` Transaction Success!🎉`,
`\n https://explorer.solana.com/tx/${signature}?cluster=devnet`
);
}最后,在代码末尾调用 sendTokens 函数:
sendTokens().catch(console.error);在终端中,运行您的代码,如果一切顺利,您将看到交易成功的消息。
常见问题
SPL 代币转账与 SOL 转账有何不同?
SPL 代币转账涉及的是关联代币账户 (ATA) 之间的操作,而非基础的系统账户。每个 ATA 都与一个特定的代币铸造 (Mint) 和钱包地址关联。转账必须在同一 Mint 的两个 ATA 之间进行,并且需要计算代币的精度 (decimals) 以确定链上表示的实际数量。
如果目标钱包没有关联代币账户 (ATA) 怎么办?
如果目标钱包还没有接收该特定 SPL 代币的 ATA,发送方需要在同一笔交易中包含创建 ATA 的指令(使用 getCreateAssociatedTokenIdempotentInstruction)。此指令是幂等的,如果账户已存在也不会失败。创建 ATA 需要支付少量的租金费用。
如何确定要转移的正确代币数量?
SPL 代币在链上以整数形式存储。实际转移的数量需要通过代币的精度 (decimals) 进行计算。例如,如果代币有 6 位小数,要转移 1 个代币,则需要传递 1 * 10^6 = 1000000 作为 amount 参数。脚本中的 getNumberDecimals 函数会自动获取并处理这个转换。
交易失败有哪些常见原因?
交易失败可能由于多种原因:支付账户 (Fee Payer) 没有足够的 SOL 支付交易费和可能的租金、源 ATA 中没有足够的代币余额、构建的交易指令不正确、或者网络连接问题。使用开发网 (Devnet) 进行测试并检查浏览器中的交易详情可以帮助排查问题。
如何获取代币的 Mint 地址?
您可以通过 Solana 区块链浏览器(如 Solana Explorer 或 Solscan)查看您钱包的代币列表,点击特定代币即可复制其 Mint 地址。在代码中,需要将此地址赋值给 MINT_ADDRESS 变量。
除了转账,还能进行哪些 SPL 代币操作?
SPL 代币程序库还支持许多其他操作,包括但不限于:铸造新代币、冻结/解冻账户、吊销铸币权限、委托代币、以及管理多签名账户。👉 获取进阶操作方法
下一步与总结
太棒了!您现在知道了如何将 SPL 代币从一个用户转移到另一个用户!想更上一层楼吗?以下是一些您可以进一步探索的想法:
- 您可以编写一个脚本将您所有的 NFT 转移到另一个钱包吗?(提示:您可能需要先研究如何获取用户钱包中的所有代币)。
- 您可以编写一个脚本向可能没有与您的铸造关联的 ATA 的一批钱包空投 SPL 代币吗?(提示:查看我们关于批量发送 SOL 分布的指南以获取灵感)。
希望本指南对您有所帮助!