Solana 上的 SPL 代币转账完整指南

·

发送 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 并存入必要的租金以使账户保持活跃。

项目设置与技术准备

所需条件

初始化项目

首先在终端中创建一个新的项目目录:

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 代币。有几种方法可以获取:

  1. 铸造您自己的同质化代币。
  2. 使用 Candy Machine 铸造一个或多个 NFT。
  3. 从 SPL 代币水龙头请求 $DUMMY 代币空投。
  4. 如果您在另一个钱包中已经拥有 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_RPCQUICKNODE_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 代币从一个用户转移到另一个用户!想更上一层楼吗?以下是一些您可以进一步探索的想法:

  1. 您可以编写一个脚本将您所有的 NFT 转移到另一个钱包吗?(提示:您可能需要先研究如何获取用户钱包中的所有代币)。
  2. 您可以编写一个脚本向可能没有与您的铸造关联的 ATA 的一批钱包空投 SPL 代币吗?(提示:查看我们关于批量发送 SOL 分布的指南以获取灵感)。

希望本指南对您有所帮助!