Token-2022 与 SPL Token:为什么你查余额查出来是空的

它们是地址不同的两个独立程序 —— 这就是为什么只查其中一个的钱包查询,会静默漏掉一半余额。

BoltTx Team··12 min read
solanatoken-2022spl-tokenprogram-id余额交易机器人

用户报告说他的余额不见了。你查了钱包,那个代币不在里面,而浏览器上明明白白显示着它

★那个代币属于 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 意味着在每个调用点重新推导程序 —— 而其中总有一个会推错。

延伸阅读

返回博客列表