用户报告说他的余额不见了。你查了钱包,那个代币不在里面,而浏览器上明明白白显示着它。
★那个代币属于 Token-2022,而你的查询问的是经典代币程序。★
是两个程序,不是两个版本
名字听起来像升级。它不是。
TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA ← ★经典 SPL Token★
TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb ← ★Token-2022★
★两个已部署的程序、两个地址,同时在线。★ 一个 mint 永久地只属于其中一个,在创建时就决定了。没有任何东西会迁移。
对你写的每一段代码,后果是:program ID 是一个参数,不是一个常量。
那个静默漏掉一半的查询
// ★只返回经典代币。★
const { value } = await connection.getParsedTokenAccountsByOwner(
wallet, { programId: TOKEN_PROGRAM_ID },
);
这是正确的代码,但结果不完整。★没有错误、没有警告 —— Token-2022 的余额就是不在数组里。★
// ★两个都查,然后合并。★
const [classic, t22] = await Promise.all([
connection.getParsedTokenAccountsByOwner(wallet, { programId: TOKEN_PROGRAM_ID }),
connection.getParsedTokenAccountsByOwner(wallet, { programId: TOKEN_2022_PROGRAM_ID }),
]);
const all = [...classic.value, ...t22.value];
两次调用,不是一次。 没有合并查询,也没有能同时返回两者的开关。
★这是最常见的 Token-2022 bug,而且它表现为"用户记错了自己的余额"。★
如果你的 program ID 都对了、交易还是会错过,免费领个 BoltTx key 改一行就能测测提交路径。
问 mint 它属于哪个程序
构建任何指令之前,一次廉价的读取就能定案:
const info = await connection.getAccountInfo(mintAddress);
const programId = info.owner; // ★mint 的 owner 就是代币程序★
★mint 账户的 owner 字段是权威答案。★ 你不需要猜、不需要维护名单、也不需要从 mint 地址推断 —— 链会直接告诉你。
把它缓存起来。 一个 mint 的所属程序永不改变,所以这是少数几个可以安全缓存整个进程生命周期的值。
ATA 推导变了
这个失败会在一个你确信存在的账户上产出 AccountNotFound:
// ★program ID 是推导的一部分。★
const ata = await getAssociatedTokenAddress(
mint,
owner,
false,
programId, // ★经典和 2022 推导出不同的地址★
);
★用错误的 program ID 推导,你会得到一个看起来合法、但根本不存在的地址。★ 错误点名的是账户,不是你的参数 —— 所以它读起来像"这个代币账户从没被创建过"。
每一个指令构造器都收同一个参数:
createTransferInstruction(src, dst, owner, amount, [], programId);
createAssociatedTokenAccountIdempotentInstruction(payer, ata, owner, mint, programId);
createCloseAccountInstruction(account, dest, owner, [], programId);
省略它会默认成经典程序 —— 这就是为什么"忘了传"在 Token-2022 的 mint 上静默失败,在其它所有地方都正常。
行为上实际差在哪
对交易机器人来说,Token-2022 的大部分是相同的。账户布局在前 165 字节一致,amount 仍然是偏移 64 处的 u64,转账行为也一样。
★不同的是扩展 —— 而其中只有一部分会改变你的交易方式。★
| 扩展 | 影响交易吗 |
|---|---|
| ★转账费★ | ★会 —— 收到的比发出的少★ |
| ★transfer hook★ | ★会 —— 插入一次 CPI,吃计算预算★ |
| ★不可转让★ | ★会 —— 你卖不掉它★ |
| ★默认冻结★ | ★会 —— 用之前要先解冻★ |
| 计息 | 只影响展示,余额不变 |
| 元数据指针 | 不影响 |
| 强制 memo | 多一条指令 |
★带星的那四个,才是会让"假设了经典行为"的机器人出问题的。★ 其余的从交易角度看只是外观差异。
扩展会改变账户大小
经典代币账户恰好 165 字节。★带扩展的 Token-2022 账户更大,而且大小随扩展种类变化。★
由此推出两件事:
dataSize: 165 的过滤器会漏掉它们。 任何用这个过滤条件的 getProgramAccounts 查询,都会静默排除掉带扩展的账户。
租金更高。 字节更多意味着租金豁免额更高,所以给一个带扩展的 mint 创建代币账户,比你可能硬编码的那个 165 字节的数字更贵。
// ★去问,不要假设。★
const rent = await connection.getMinimumBalanceForRentExemption(info.data.length);
一个实用做法
让这件事保持可控的模式,是解析一次程序,然后一路带着它走:
async function resolveToken(connection, mint) {
const info = await connection.getAccountInfo(mint);
if (!info) throw new Error("mint 不存在");
const programId = info.owner;
const is2022 = programId.equals(TOKEN_2022_PROGRAM_ID);
return {
programId,
is2022,
// ★只有 2022 的 mint 才可能带值得检查的扩展。★
extensions: is2022 ? await readExtensions(connection, mint) : [],
};
}
★把"属于哪个代币程序"当成代币身份的一部分,在发现时解析好、和 mint 地址一起携带。★ 只存 mint 地址的机器人,最后会在每个调用点重新推导程序 —— 而其中总有一个会推错。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★错误的 program ID 在提交速度变得相关之前就失败了★ —— 要么在指令构建阶段,要么是一次立即的 revert。这是正确性问题,不是上链问题,没有任何提交路径能帮上忙。
BoltTx 在这里的位置
我们负责提交。你的指令引用哪个代币程序,完全在你的代码里决定。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。我们不修改交易内容,这包括绝不替换 program ID。
你在本地签名。我们不托管资金、不代签。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
Token-2022 和 SPL Token 有什么区别? 它们是两个已部署、program ID 不同的独立程序,都在运行。一个 mint 在创建时就只属于其中一个,而且不会迁移。
Token-2022 是 SPL Token 的升级版吗? 不是,尽管名字听起来像。经典代币程序继续原样运行,Token-2022 与它并存,支持一批可选扩展。
getParsedTokenAccountsByOwner 里为什么少了某个代币?
因为这个查询限定在一个 program ID 上。Token-2022 的余额需要用 TOKEN_2022_PROGRAM_ID 再查一次,而且没有能同时返回两者的合并查询。
怎么知道一个 mint 用的是哪个代币程序?
读 mint 账户的 owner 字段。它就是拥有这个 mint 的代币程序,这是权威答案,不需要猜也不需要维护名单。
账户明明存在,为什么报 AccountNotFound? 多半是用错 program ID 推导了 ATA。program ID 是推导的一部分,所以经典和 Token-2022 对同一个钱包和 mint 会推出不同的地址。
指令构造器需要传 program ID 吗? Token-2022 需要。省略它会默认成经典程序 —— 这就是为什么这个错误在别处都正常、只在 Token-2022 的 mint 上失败。
账户布局一样吗? 前 165 字节一样 —— mint 在 0、owner 在 32、amount 在 64。带扩展的 Token-2022 账户更大,扩展数据追加在这之后。
我的 dataSize 165 过滤器为什么漏掉 Token-2022 账户?
因为带扩展的账户超过 165 字节。按这个精确大小过滤会静默排除它们,这是 getProgramAccounts 结果不完整的常见原因。
哪些 Token-2022 扩展会影响交易? 转账费、transfer hook、不可转让、默认冻结。其余的从交易角度基本是外观差异,不过强制 memo 会多一条指令。
Token-2022 的租金更贵吗?
带扩展的账户是的,因为它们占更多字节。用实际数据长度去查 getMinimumBalanceForRentExemption,不要假设 165 字节。
mint 能从 SPL Token 迁移到 Token-2022 吗? 不能。所属程序在创建时就固定了。 想要 Token-2022 特性的项目必须新建一个 mint,并自己处理过渡。
DEX 支持 Token-2022 代币吗? 支持程度因协议和扩展而异。transfer hook 尤其需要显式处理,所以用一笔小额交易验证,不要假设。
怎么展示用户的完整余额? 两个 program ID 都查,然后合并结果。 只查一个程序,是"浏览器上有、你这里没有"最常见的原因。
该缓存 mint 属于哪个程序吗? 该,而且可以永久缓存。所属程序永不改变,这让它成为少数几个真正可以缓存整个进程生命周期的值。
对 Token-2022 的 mint 用经典 program ID 会怎样? 指令失败,因为这个 mint 不归你调用的那个程序所有。这是一个立刻被抓到的正确性错误,不是间歇性问题。
机器人该怎么存代币身份? mint 地址连同它的 program ID,在发现时解析一次。 只存 mint 意味着在每个调用点重新推导程序 —— 而其中总有一个会推错。