一个跑了好几个月的机器人,突然只在某一个代币上不停 revert。指令一模一样,余额没问题,滑点设置也没改过。
★那个代币用了 Token-2022 的转账费扩展 —— 到账的金额比发出的少。★
手续费是在哪里被扣掉的
用经典 SPL 代币,转 1,000 个到账就是 1,000 个。而带转账费扩展时,代币程序会在转账内部扣掉一个百分比:
你发出 1,000
手续费(1%) 10 ← ★由代币程序扣下★
对方收到 990 ← ★下一步看到的是这个★
★这笔费用不是一条你能检查的独立指令。它发生在转账内部。★ 你的指令列表里没有任何东西提到它 —— 这就是它在下游出问题之前完全不可见的原因。
两个参数定义它:
transferFeeBasisPoints —— 费率,以万分之一为单位。
maximumFee —— 一个绝对上限,所以大额转账不会被无限按比例收费。
import { getTransferFeeConfig, getMint } from "@solana/spl-token";
const mint = await getMint(connection, mintAddress, "confirmed", TOKEN_2022_PROGRAM_ID);
const feeConfig = getTransferFeeConfig(mint);
if (feeConfig) {
const { transferFeeBasisPoints, maximumFee } = feeConfig.newerTransferFee;
}
★getTransferFeeConfig 返回 null 说明这个 mint 没有费用扩展★ —— 而这是常见情况,也是"从不检查它的代码"能一直好用、直到遇上一个有费用的代币为止的原因。
你的最小输出为什么会失败
swap 会把一个最小输出量写进指令,由程序在链上强制。★这个检查针对的是实际到账的量,不是你算出来的量。★
// ★错:假设全额到账 —— 没扣手续费。★
// const minOut = expectedOut * (10_000n - slippageBps) / 10_000n;
// ★对:手续费在检查之前就被扣掉了。★
const fee = calculateFee(expectedOut, feeConfig);
const minOut = (expectedOut - fee) * (10_000n - slippageBps) / 10_000n;
不做这个调整,1% 的转账费配 1% 的滑点容忍度,会每一次都 revert —— 到账量按构造就低于你的最小值,而重试多少次都改变不了。
这就是这种失败看起来那么奇怪的原因。 它不是间歇性的、和拥堵无关、调高优先费也没用。★它在一个代币上确定性地失败,在其它每个代币上都正常。★
如果你的费用计算是对的、交易还是会错过,免费领个 BoltTx key 改一行就能测测提交路径。
计算这笔费用
function calculateFee(amount: bigint, cfg): bigint {
const { transferFeeBasisPoints, maximumFee } = cfg.newerTransferFee;
const fee = (amount * BigInt(transferFeeBasisPoints)) / 10_000n;
return fee > maximumFee ? maximumFee : fee; // ★上限生效★
}
★注意 newerTransferFee。★ 这个扩展存了两套配置 —— 当前的,和一套将在未来某个 epoch 生效的。读错字段,拿到的是一个还没生效的费率。
const epoch = (await connection.getEpochInfo()).epoch;
const active = BigInt(epoch) >= cfg.newerTransferFee.epoch
? cfg.newerTransferFee
: cfg.olderTransferFee;
★费率表可以在 epoch 边界改变。★ 一个启动时读一次配置、然后永久缓存的机器人,在切换之后用的就是陈旧费率 —— 而那产出的正是上面那种确定性 revert,在一个昨天还好好的代币上一夜之间冒出来。
指令也要换
createTransferInstruction 不知道有费用这回事。带检查的那个知道:
import { createTransferCheckedWithFeeInstruction } from "@solana/spl-token";
const ix = createTransferCheckedWithFeeInstruction(
source, mint, destination, owner,
amount, // ★毛额 —— 从来源账户出去的量★
decimals,
fee, // ★必须和程序算出来的一致★
[],
TOKEN_2022_PROGRAM_ID,
);
★你传的费用必须等于程序算出来的,否则指令失败。★ 它是一条 checked 指令 —— 这个值是一个断言,不是一个输入。 用陈旧配置算出来的值,和上面是同一个 bug,只是在另一个点上暴露。
这对你的记账意味着什么
★毛额和净额分岔了,而只记录其中一个数字的机器人,记的是错的那个。★
// ★读实际动了什么,不是你打算动什么。★
const pre = tx.meta.preTokenBalances ?? [];
const post = tx.meta.postTokenBalances ?? [];
// 目标账户上的差额,是扣费之后的净额。
三个受影响的地方:
仓位规模。 你持有的比你买的少。按买入量去算平仓,是在试图卖出从没到账的代币。
盈亏。 这笔费用是真实成本,却既不出现在你的手续费账里,也不出现在滑点账里。
来回一趟。 ★买和卖都要付★,所以 1% 的转账费在一个完整来回上大约是 2% —— 这可能超过其它所有成本之和。
在交易它之前先检测
async function checkTransferFee(connection, mintAddress) {
const info = await connection.getAccountInfo(mintAddress);
if (!info) return { exists: false };
// ★owner 告诉你它属于哪个代币程序 —— 这是第一道检查。★
const is2022 = info.owner.equals(TOKEN_2022_PROGRAM_ID);
if (!is2022) return { is2022: false, hasFee: false };
const mint = await getMint(connection, mintAddress, "confirmed", TOKEN_2022_PROGRAM_ID);
const cfg = getTransferFeeConfig(mint);
return { is2022: true, hasFee: !!cfg, cfg };
}
★mint 的 owner 字段是最便宜的检查,而且它先回答了最重要的那个问题。★ 如果 owner 是经典代币程序,上面这一切都不适用,后面的可以全跳过。
把这一项加进你的代币安全检查,和 mint authority、freeze authority 放在一起。★一个当前为零的费率,持有配置权限的人之后随时能调高★ —— 所以"今天没有费用"不等于"没有费用风险"。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★一次转账费导致的 revert 拿不到那个数字 —— 它落块、revert,然后让你付掉基础手续费。★ 这让它成为值得在费用计算里抓住、而不是在生产环境抓住的东西,因为重试修不好一个算术错误。
BoltTx 在这里的位置
我们负责提交。费用扩展是代币的属性,完全在你的指令构建里处理。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。我们不修改交易内容,这包括绝不碰你指令里的金额。
你在本地签名。我们不托管资金、不代签。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
Token-2022 的转账费扩展是什么? 一项 mint 配置,在代币程序内部对每一次转账扣下一个百分比。接收方拿到的比发送方发出的少,而且没有任何独立指令会揭示它。
我的 swap 为什么只在某一个代币上 revert? 多半是转账费。到账量低于你的最小输出,因为费用在转账时就被扣掉了 —— 这让 revert 是确定性的,而不是间歇性的。
怎么检查一个代币有没有转账费?
先读 mint 的 owner 字段 —— 不是 Token-2022 程序就没有费用。是的话再调 getTransferFeeConfig,没有费用扩展时它返回 null。
转账费怎么算?
金额乘以 transferFeeBasisPoints 除以 10,000,并以 maximumFee 封顶。这个上限意味着大额转账不会被无限按比例收费。
newerTransferFee 和 olderTransferFee 有什么区别?
这个扩展存了当前的和一套待生效的配置。拿当前 epoch 和 epoch 字段比较,才能确定哪一套真正生效。
我开始交易之后,转账费会变吗? 会,在 epoch 边界、由持有配置权限的人改。启动时缓存配置的机器人会用陈旧费率,并在变更生效后开始确定性 revert。
怎么为转账费调整滑点? 在套用滑点容忍度之前,先把费用从预期输出里减掉。 1% 的费率配 1% 的容忍度会每次都 revert,因为到账量按构造就低于最小值。
带费用的代币该用哪条指令?
createTransferCheckedWithFeeInstruction,传毛额和费用。费用是被断言的而不是被套用的,所以用陈旧配置算出的值会让指令失败。
转账费买和卖都收吗? 都收。一个来回要付两次,所以 1% 的费率在完整一趟上大约是 2% —— 这可能超过网络手续费和滑点加起来的成本。
被扣下的费用去哪了? 它累积在接收方的代币账户里,之后会归集到 mint,由提取权限方取走。不管怎样,它已经离开你的仓位了。
getTokenAccountBalance 会显示这笔费用吗? 不会。它显示的是结果余额,已经是扣费之后的净额。 要看扣了多少,得比对交易 meta 里的 pre/post 代币余额。
转账费会影响我的仓位追踪吗? 会。你收到的比你买的少,所以按买入量去算平仓,是在试图卖出从没到账的代币。要从交易 meta 里取净额来记录。
聚合器会自动处理转账费吗? 很多会,但不要假设。先用一个带费用的代币做一笔小额交易验证,因为失败方式是确定性 revert 而不是一条警告。
转账费和"税费币"是一回事吗? 它是同一个想法的标准层版本。老的税费币是用自定义程序逻辑实现的,而 Token-2022 把它做成了由代币程序强制的 mint 扩展。
该避开有转账费的代币吗? 不一定,但要把它计入成本。它在两条腿上都是真实成本,一个利润率很薄的策略,在算完来回之后可能撑不住。
怎么把这项加进代币安全检查? 和 mint authority、freeze authority 一起,检查 mint 的 owner 和费用配置。★注意今天为零的费率,之后随时可能被调高★,所以配置权限和当前费率同样重要。