Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
网络与协议指南阅读约 13 分钟

Solana 上的 Token-2022 转账手续费:预扣金额与 RPC 读取

一份以 RPC 为核心的实用指南,介绍如何从 Solana 账户和交易数据中读取 Token-2022 转账手续费、预扣金额以及扣除手续费后的实际余额。

TL;DR

Token-2022 转账手续费在转账时并不会支付给接收方或某个手续费账户;发送方被扣除全额,接收方入账金额减去手续费,而手续费则累积在 mint 的预扣金额中。手续费费率和上限按 epoch 在 TransferFeeConfig mint 扩展中定义,因此适用费率取决于当前 epoch 和 mint 的配置。要观察一笔已完成转账扣除手续费后的实际金额,应读取 getTransaction meta 中的 preTokenBalances 和 postTokenBalances,而不是轻信指令金额。要读取累积的预扣金额,需从 mint 账户解码 TransferFeeConfig 扩展;对钱包调用 getTokenAccountBalance 不会显示该金额。本指南提供可运行的 Node.js 示例和一张结果表,供你针对自己的 RPC 端点测量这些值。

TransferFeeConfig Mint 扩展及其字段所在位置

TransferFeeConfig 扩展是由 Token-2022 程序定义的 mint 扩展。根据 Solana 转账手续费参考文档(https://solana.com/docs/references/token-extensions),它存储转账手续费基点、最大手续费、提取权限以及预扣金额累加器。这些字段属于 mint 账户数据,而非 token 账户数据,因此任何想要分析手续费的客户端都必须获取并解码 mint。

该扩展被追加在基础 mint 布局之后。基础 mint 账户包含 mint 权限、供应量、小数位数和冻结权限等标准字段。TransferFeeConfig 扩展紧随基础数据之后,并以类型判别符和长度前缀开头。确切的字节偏移取决于 Token-2022 程序版本以及其他扩展是否存在,因此稳健的解码器应解析扩展列表,而不是假定固定偏移。

提取权限是唯一被允许从 mint 提取累积预扣手续费的账户。预扣金额是从转账中预扣但尚未提取的手续费的滚动总额。它不是 token 账户余额,也不会出现在任何钱包的 getTokenAccountBalance 中。

该扩展及其字段由 Solana 在转账手续费参考文档中定义,mint 扩展账户布局则与 Token Extensions 程序文档一同记录。下文使用的读取方法——getTokenAccountBalance、getAccountInfo 和 getTransaction——在 Solana JSON-RPC API 参考中有明确规定。请以这些为准;本文是在它们之上构建的操作性读取流程。

  • TransferFeeConfig 是 mint 扩展,不是 token 账户扩展。
  • 字段包括转账手续费基点、最大手续费、提取权限和预扣金额。
  • 预扣金额累积在 mint 上,只有提取权限可以提取。
  • 解码时应考虑扩展顺序可变和 dataSize 的情况。

Token-2022 转账如何转移价值并预扣手续费

在 Token-2022 下,转账指令会从发送方的 token 账户扣除指令全额。接收方的 token 账户入账金额减去计算出的手续费。手续费本身当时并不会转移到任何账户;相反,它会被加到 mint 的预扣金额中。此行为在 Solana 转账手续费参考文档中有记录,也是与简单 SPL Token 转账的核心区别。

由于手续费预扣在 mint 上,某个 mint 的所有 token 账户余额之和可能小于该 mint 的总供应量。差额就是预扣金额,它留在 mint 上,直到提取权限将其提取。这意味着,将 token 账户与供应量进行简单对账时,会出现等于累积预扣手续费的差异。

手续费计算使用 TransferFeeConfig 扩展中的转账手续费基点和最大手续费。基点应用于转账金额,结果以最大手续费为上限。确切的舍入和上限行为由 Token-2022 程序定义,应针对具体程序版本的程序源码或官方文档进行核实。

  • 发送方被扣除全额;接收方入账金额减去手续费。
  • 手续费被加到 mint 的预扣金额中,而不是支付给某个账户。
  • 所有 token 账户余额之和可能比 mint 供应量少一个预扣金额。
  • 手续费为 min(金额乘以基点, 最大手续费)。

Epoch 费率窗口使手续费计算依赖状态

TransferFeeConfig 扩展以基于 epoch 的窗口定义费率。根据 Token-2022 文档,该扩展存储新旧两组手续费参数,每组都包含基点值、最大手续费和 epoch。一笔转账的适用费率取决于当前 epoch 相对于这些 epoch 值的位置。这使手续费计算依赖状态:相同的转账金额可能因当前 epoch 和 mint 配置的窗口不同而产生不同手续费。

想要计算候选转账手续费的客户端,必须从 RPC 节点读取当前 epoch,然后从 mint 的 TransferFeeConfig 中选择正确的手续费参数。如果当前 epoch 大于或等于较新的 epoch,则适用较新的参数;否则适用较旧的参数。确切的比较逻辑由 Token-2022 程序定义,应依据官方文档确认。

由于手续费参数可能在 epoch 边界发生变化,某一时刻计算出的手续费可能与之后观察到的手续费不一致。对于历史转账,适用的是转账发生时生效的手续费参数,这可能需要读取历史 mint 状态或依赖交易自身的 meta 数据。

  • TransferFeeConfig 存储带 epoch 的新旧两组手续费参数。
  • 适用费率取决于当前 epoch 相对于所存 epoch 的位置。
  • 手续费计算依赖状态,并可能在 epoch 边界发生变化。
  • 历史手续费重建可能需要历史 mint 状态或交易 meta。

从 getTransaction Meta 读取扣除手续费后的实际金额

Token-2022 转账中的指令金额是扣除手续费前的值。要观察接收方实际收到的金额,请读取 getTransaction 返回的交易 meta。meta 包含 preTokenBalances 和 postTokenBalances 数组,分别列出交易前后的 token 账户余额。接收方 token 账户的 post 余额与 pre 余额之差,就是扣除手续费后的实际金额。

这种方法比根据指令金额计算手续费更可靠,因为它反映的是实际链上结果,包括任何舍入、上限应用或程序特定行为。它还避免了对每笔转账都解码 mint 扩展的需要,不过要理解手续费参数和预扣金额,解码 mint 仍然是必要的。

读取 getTransaction 时,请确保交易已最终确定,或至少按你所需的 commitment 级别达到已确认。对于失败或尚不可用的交易,meta 可能为 null。关于 commitment 级别的更多内容,请参阅 Solana commitment 级别与交易确认

  • 指令金额是扣除手续费前的值;meta 余额显示扣除手续费后的实际结果。
  • preTokenBalances 和 postTokenBalances 以账户索引和 mint 为键。
  • 接收方的 post 余额减去 pre 余额即为实际收到金额。
  • 对于失败或不可用的交易,meta 可能为 null。

从 Mint 账户读取累积预扣金额

预扣金额存储在 mint 账户的 TransferFeeConfig 扩展中。要读取它,请使用 getAccountInfo 获取 mint 账户并解码该扩展。预扣金额是一个 u64,表示已预扣但尚未提取的手续费总额。它随每笔产生手续费的转账而增加,并在提取权限提取手续费时减少。

对钱包调用 getTokenAccountBalance 不会显示预扣金额,因为它不是 token 账户余额。预扣金额属于 mint,而不属于任何用户的 token 账户。这是对账时常见的困惑来源。关于读取账户数据的更多内容,请参阅通过 RPC 读取 Solana 账户:数据、租金和 token 账户

解码 mint 账户需要解析扩展列表。基础 mint 数据之后是一系列扩展,每个扩展都有类型和长度。TransferFeeConfig 扩展具有特定的类型值和已知布局。稳健的解码器应能优雅地处理意外的 dataSize 和未知扩展。

  • 预扣金额位于 mint 的 TransferFeeConfig 扩展中。
  • 它无法通过对钱包调用 getTokenAccountBalance 看到。
  • 它随手续费增加,并在提取时减少。
  • 解码必须处理扩展顺序可变和 dataSize 的情况。

可运行的 Node.js 示例:获取 Mint、解码 TransferFeeConfig、计算手续费

以下 Node.js 示例使用 @solana/web3.js 获取 mint 账户、解码 TransferFeeConfig 扩展、读取当前 epoch,并计算候选转账金额的手续费。它假定该 mint 具有 TransferFeeConfig 扩展,并且对于所用程序版本,该扩展位于已知偏移处。在实践中,你应解析扩展列表以找到正确的偏移。

该示例使用 getAccountInfo 获取 mint,使用 getEpochInfo 读取当前 epoch,并使用一个简单的解码器处理 TransferFeeConfig 字段。它会打印基点、最大手续费、预扣金额,以及给定转账金额下计算出的接收方金额。

const { Connection, PublicKey } = require('@solana/web3.js');

const RPC_URL = process.env.RPC_URL || 'https://api.mainnet-beta.solana.com';
const MINT = new PublicKey(process.env.MINT || 'YourMintAddressHere');
const TRANSFER_AMOUNT = BigInt(process.env.TRANSFER_AMOUNT || '1000000');

async function main() {
  const connection = new Connection(RPC_URL, 'confirmed');
  const mintInfo = await connection.getAccountInfo(MINT);
  if (!mintInfo) throw new Error('Mint account not found');

  const data = mintInfo.data;
  // Base mint layout: 82 bytes for SPL Token; Token-2022 base is similar.
  // Extensions start after base data. This example assumes TransferFeeConfig
  // is the first extension and uses a simplified offset for demonstration.
  const baseLen = 82;
  const extType = data.readUInt16LE(baseLen);
  const extLen = data.readUInt16LE(baseLen + 2);
  if (extType !== 1) throw new Error('TransferFeeConfig extension not found at expected offset');

  const extData = data.slice(baseLen + 4, baseLen + 4 + extLen);
  // TransferFeeConfig layout (simplified):
  // withdrawAuthority (32), withheldAmount (8), olderEpoch (8), olderBps (2), olderMax (8),
  // newerEpoch (8), newerBps (2), newerMax (8)
  let offset = 0;
  const withdrawAuthority = new PublicKey(extData.slice(offset, offset + 32)); offset += 32;
  const withheldAmount = extData.readBigUInt64LE(offset); offset += 8;
  const olderEpoch = extData.readBigUInt64LE(offset); offset += 8;
  const olderBps = extData.readUInt16LE(offset); offset += 2;
  const olderMax = extData.readBigUInt64LE(offset); offset += 8;
  const newerEpoch = extData.readBigUInt64LE(offset); offset += 8;
  const newerBps = extData.readUInt16LE(offset); offset += 2;
  const newerMax = extData.readBigUInt64LE(offset); offset += 8;

  const epochInfo = await connection.getEpochInfo();
  const currentEpoch = BigInt(epochInfo.epoch);

  let bps, maxFee;
  if (currentEpoch >= newerEpoch) {
    bps = newerBps; maxFee = newerMax;
  } else {
    bps = olderBps; maxFee = olderMax;
  }

  const fee = (TRANSFER_AMOUNT * BigInt(bps)) / 10000n;
  const cappedFee = fee > maxFee ? maxFee : fee;
  const recipientAmount = TRANSFER_AMOUNT - cappedFee;

  console.log('Mint:', MINT.toBase58());
  console.log('Withdraw authority:', withdrawAuthority.toBase58());
  console.log('Withheld amount:', withheldAmount.toString());
  console.log('Current epoch:', currentEpoch.toString());
  console.log('Applicable bps:', bps);
  console.log('Applicable max fee:', maxFee.toString());
  console.log('Transfer amount:', TRANSFER_AMOUNT.toString());
  console.log('Computed fee:', cappedFee.toString());
  console.log('Recipient amount:', recipientAmount.toString());
}

main().catch(console.error);

可运行的 Node.js 示例:从交易 Meta 读取扣除手续费后的金额

以下示例按签名获取交易,并从 preTokenBalances 和 postTokenBalances 中提取接收方扣除手续费后的金额。它使用 jsonParsed 编码的 getTransaction 来简化余额数组。这是观察接收方实际收到金额最直接的方式。

该示例假定交易已确认且 meta 可用。它会打印每个 token 账户的 pre 和 post 余额,并计算接收方的差额。你可以调整它,按 mint 或 owner 进行过滤。

const { Connection } = require('@solana/web3.js');

const RPC_URL = process.env.RPC_URL || 'https://api.mainnet-beta.solana.com';
const SIGNATURE = process.env.SIGNATURE || 'YourTransactionSignatureHere';

async function main() {
  const connection = new Connection(RPC_URL, 'confirmed');
  const tx = await connection.getTransaction(SIGNATURE, {
    maxSupportedTransactionVersion: 0,
    commitment: 'confirmed'
  });
  if (!tx) throw new Error('Transaction not found');
  if (!tx.meta) throw new Error('Transaction meta is null');

  const pre = tx.meta.preTokenBalances || [];
  const post = tx.meta.postTokenBalances || [];

  console.log('Pre token balances:');
  for (const b of pre) {
    console.log('  accountIndex:', b.accountIndex, 'mint:', b.mint, 'amount:', b.uiTokenAmount.uiAmountString);
  }
  console.log('Post token balances:');
  for (const b of post) {
    console.log('  accountIndex:', b.accountIndex, 'mint:', b.mint, 'amount:', b.uiTokenAmount.uiAmountString);
  }

  // Compute difference for each account index present in both
  for (const p of post) {
    const preBal = pre.find(x => x.accountIndex === p.accountIndex);
    if (preBal) {
      const preAmt = BigInt(preBal.uiTokenAmount.amount);
      const postAmt = BigInt(p.uiTokenAmount.amount);
      const diff = postAmt - preAmt;
      console.log('Account index', p.accountIndex, 'delta:', diff.toString());
    }
  }
}

main().catch(console.error);

用于针对自己端点测量的结果表

使用下表记录来自你自己 RPC 端点的测量结果。填写 mint 地址、扩展是否存在、基点、最大手续费、当前预扣金额、计算出的接收方金额,以及从某笔交易中观察到的 post 余额。这将帮助你验证解码和手续费计算是否与链上行为一致。

运行第一个 Node.js 示例以填充与 mint 相关的列,然后对一笔已知转账运行第二个示例以填充观察到的 post 余额。将计算出的接收方金额与观察到的 post 余额减去 pre 余额进行比较。差异可能表明扩展偏移不正确、epoch 不匹配或程序版本不同。

  • Mint 地址:______________________________
  • TransferFeeConfig 扩展是否存在(是/否):______________________________
  • 基点(适用):______________________________
  • 最大手续费(适用):______________________________
  • 当前预扣金额:______________________________
  • 候选转账计算出的接收方金额:______________________________
  • 接收方观察到的 post 余额减去 pre 余额:______________________________
  • 差异说明:______________________________

故障模式与排查

没有 TransferFeeConfig 扩展的 mint 不会有转账手续费。如果你尝试解码该扩展却发现意外的类型或长度,该 mint 很可能没有该扩展。某些工具中的“token extensions false”表示该扩展不存在。在这种情况下,转账行为与标准 SPL Token 转账相同,没有预扣手续费。

一笔手续费与简单百分比预测不符的交易,可能触发了最大手续费上限。手续费为 min(金额乘以基点, 最大手续费)。如果计算出的百分比超过最大手续费,则适用最大手续费。请始终检查当前 epoch 适用的最大手续费。

如果 mint 具有额外扩展或不同布局,可能会因意外的 dataSize 导致账户数据解码错误。基础 mint 数据长度和扩展顺序可能不同。稳健的解码器应通过读取每个扩展的类型和长度来解析扩展列表,直到账户数据末尾。关于账户数据的更多内容,请参阅通过 RPC 读取 Solana 账户:数据、租金和 token 账户

如果 getTransaction 返回的 meta 为 null,交易可能已失败、尚未确认或已被修剪。请使用适当的 commitment 级别,并考虑对历史交易使用归档访问。关于交易 meta 的更多内容,请参阅解码 Solana 交易 meta 与内部指令

  • 缺少扩展:没有转账手续费;“token extensions false”表示不存在。
  • 最大手续费上限可能使实际手续费低于简单百分比。
  • 意外的 dataSize:解析扩展列表,而不是使用固定偏移。
  • Meta 为 null:检查 commitment、交易状态和归档可用性。

基于 RPC 读取手续费的局限与权衡

根据指令金额计算手续费是错误的,因为指令金额是扣除手续费前的值。实际手续费由 mint 的 TransferFeeConfig 和当前 epoch 决定,而接收方的余额变化才是权威的扣除手续费后金额。依赖指令金额可能导致错误的账务处理。

每次读取都获取并解码完整 mint 数据是有成本的。每次读取都需要一次 getAccountInfo 的 RPC 调用,可能还需要 getEpochInfo。对于高频应用,这会增加延迟和负载。使用短 TTL 缓存 mint 数据会有所帮助,但必须在 epoch 边界或 mint 配置变化时使其失效。

历史转账可能需要归档访问,因为在非归档节点上,旧交易的 getTransaction meta 并不总是可用。历史数据的可用性因提供商而异。关于提供商特定行为,请查阅你的 RPC 提供商的文档。关于 RPC 定价和服务级别的更多内容,请参阅 RPC 定价API 服务

  • 指令金额是扣除手续费前的值;不要将其用作收到金额。
  • 每次读取都完整获取并解码 mint 会增加成本;缓存需谨慎。
  • 历史 meta 可能需要归档访问;可用性因提供商而异。
  • 提供商特定的限制和保留策略应向你的提供商确认。

集成 Token-2022 手续费读取的后续步骤

要将 Token-2022 手续费读取集成到你的应用中,首先解码 mint 的 TransferFeeConfig 扩展,并按 epoch 缓存适用的手续费参数。然后,对每笔转账读取交易 meta,以确认扣除手续费后的实际金额。使用结果表针对你自己的 RPC 端点验证你的实现。

关于更广泛的 Solana RPC 覆盖,请参阅 Solana RPC 端点(RPC Assistant)OnFinality Learn 中心。如果你需要查询大量 mint 或 token 账户,请考虑使用 getProgramAccounts 过滤器与 dataSlice 分页 来减少数据传输。关于带预检检查的交易发送,请参阅 Solana sendTransaction 预检错误处理

最后,请查阅 Solana 官方关于转账手续费和 Token-2022 程序的文档,以确认最新的扩展布局和手续费逻辑。Solana JSON-RPC API 参考中关于 getTokenAccountBalance、getAccountInfo 和 getTransaction 的内容,是本文所用方法契约的权威依据。

  • 按 epoch 解码并缓存 TransferFeeConfig。
  • 使用交易 meta 和结果表进行验证。
  • 使用 getProgramAccounts 过滤器进行批量读取。
  • 对照官方文档确认扩展布局。

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始