查询 Solana 代币余额而不踩精度坑

为什么 uiAmount 不能用来做判断、一个钱包和一批账户分别怎么查,以及会让机器人出错的 SOL 与 wSOL 之分。

BoltTx Team··12 min read
solana代币余额spl-token精度rpc交易机器人

读一个代币余额看起来是个已经解决的问题。RPC 返回一个数字,你用这个数字。

★而它返回的最顺手的那个数字,恰恰是你不该拿来做判断的那个。★

让你付代价的那个字段

getTokenAccountBalance 会返回同一个值的三种表示:

const { value } = await connection.getTokenAccountBalance(tokenAccount);

value.amount;        // ★"1234567890123" —— 字符串,精确★
value.decimals;      // 9
value.uiAmount;      // ★1234.567890123 —— number,有损★
value.uiAmountString // "1234.567890123" —— 字符串,可安全展示

uiAmount 是 JavaScript 的 number,无法精确表示 2^53 以上的整数。★ 一个 9 位小数的代币,在大约九百万单位处就越过了这个门槛 —— 对一个 meme 币余额来说完全正常。

// ★错:在有损浮点数上做比较。★
if (value.uiAmount >= threshold) { sell(); }

// ★对:在原始单位上比较。★
const raw = BigInt(value.amount);
const thresholdRaw = BigInt(Math.floor(threshold * 10 ** value.decimals));
if (raw >= thresholdRaw) { sell(); }

每一次判断用 amount,每一次展示用 uiAmountString,uiAmount 两样都别用。 这个失败只在大额余额上出现,所以它能躲过测试、然后在生产环境冒出来。

查一整个钱包

一次调用就能返回某个钱包拥有的全部代币账户:

const { value } = await connection.getParsedTokenAccountsByOwner(
  wallet,
  { programId: TOKEN_PROGRAM_ID },
);

for (const { pubkey, account } of value) {
  const info = account.data.parsed.info;
  console.log(info.mint, info.tokenAmount.amount);   // ★字符串★
}

★这里有两件事会让人意外。★

空账户也在里面。 一个交易过很多代币的钱包会累积一堆余额为零的账户,每一个都还锁着租金。展示时把它们过滤掉,并考虑关闭它们把租金收回来。

Token-2022 是另一个程序。 新代币程序下的账户,不会被一个限定在 TOKEN_PROGRAM_ID 的查询返回。如果某个余额不见了、而那个 mint 看起来不太一样,再查一次 TOKEN_2022_PROGRAM_ID

如果你的余额读取没问题、问题在交易落不了块,免费领个 BoltTx key 改一行就能测测提交路径。

一次读很多账户

在循环里逐个取余额,是机器人撞上限流最常见的方式:

// ★不要这么写。★
for (const acct of accounts) {
  await connection.getTokenAccountBalance(acct);
}

// ★一个请求,最多 100 个账户。★
const infos = await connection.getMultipleAccountsInfo(accounts);

const balances = infos.map((info) =>
  info ? info.data.readBigUInt64LE(64) : 0n     // ★amount 在偏移 64★
);

getMultipleAccountsInfo 每次调用最多收 100 个地址。★ 对一个要跨很多代币追踪仓位的机器人来说,这就是一个请求和一百个请求的差别 —— 而解析只是在一个已知偏移处做一次 readBigUInt64LE

某一项是 null 说明该账户不存在,对代币账户来说意味着这个钱包从没持有过那个代币那不是错误,也不等同于零余额 —— 零余额意味着账户存在且是空的。

SOL 不是代币账户

这个区分会在最糟糕的时刻让机器人出错:

// ★原生 SOL —— 钱包本身上的 lamport。★
const lamports = await connection.getBalance(wallet);

// ★wrapped SOL —— 和其它一样的代币账户。★
const wsol = await getAssociatedTokenAddress(NATIVE_MINT, wallet);
const { value } = await connection.getTokenAccountBalance(wsol);

★这是两个不同的余额,而且它们不同步变动。★ 包装 SOL 会创建一个代币账户并把 lamport 转进去;解包会关闭账户并把它们退回来。

两个实操后果:

在一笔花 wSOL 的 swap 之前去查原生 SOL,对这笔 swap 能否成功什么都说明不了。

★原生 SOL 不是全部可花的。★ 租金豁免的那部分必须留着,而交易手续费也从同一份余额里出 —— 所以"我有 SOL"和"我能花这么多 SOL"是两句不同的话。

const rentExempt = await connection.getMinimumBalanceForRentExemption(0);
const spendable = lamports - rentExempt - feeBuffer;

这就是一个显示着余额的钱包,仍然会报 insufficient funds 的原因。

余额读取是某一时刻的快照

余额只对它被读取的那个 slot 为真,而基于它行动的机器人,是在基于过去行动

// ★用和这个决策相称的 commitment 读。★
const { value } = await connection.getTokenAccountBalance(acct, "confirmed");

processed 最快、但可能被回滚 —— 用来决定要尝试什么没问题,用来记录任何东西就是错的而真正的保护不是更严格的 commitment,是一个链上检查: 把最小输出量写进指令里让程序去强制它,而不是依赖你片刻之前读到的余额

★一次余额读取是你决策的输入,不是关于执行结果的保证。★

上链应该是什么水平

我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。

★从读到余额到交易落块之间的每一个 slot,都是这个余额可以改变的时间。★ 落得更快能收窄这个窗口;正确的链上检查让这个窗口变得可以承受。

BoltTx 在这里的位置

我们负责提交,不做余额查询。你读取用什么,保持原样。

提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。因为端点互相独立,繁重的余额轮询耗尽不了你交易发出去的那条路径。

你在本地签名。我们不托管资金、不代签、不修改交易内容。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。

免费领 API key,没有月费:

const sender = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

常见问题

Solana 上怎么查代币余额? 对代币账户地址调 getTokenAccountBalance,然后用 amount 字符串而不是 uiAmount。查整个钱包用 getParsedTokenAccountsByOwner,一次返回全部账户。

为什么不该用 uiAmount? 它是 JavaScript 的 number,无法精确表示 2^53 以上的整数。9 位小数下这个门槛大约是九百万代币,所以大额余额会静默丢精度。

amount 和 uiAmount 有什么区别? amount 是最小单位下的精确原始值,以字符串给出。uiAmount 是它除以小数位之后的浮点数,便于展示,但拿来比较不安全。

怎么安全地比较代币余额?amount 转成 bigint,在原始单位上比较。把你的阈值按小数位放大,而不是把余额缩小 —— 这样在做决定之前不会丢精度。

怎么一次查很多代币余额? getMultipleAccountsInfo 每次最多收 100 个地址。在偏移 64 处用 readBigUInt64LE 读金额,那就是代币账户存放它的位置。

getParsedTokenAccountsByOwner 里为什么少了某个代币? 多半它是一个 Token-2022 的 mint,属于另一个程序。要再查一次 TOKEN_2022_PROGRAM_ID,因为限定在原代币程序的查询不会返回它。

取余额时返回 null 是什么意思? 账户不存在,意味着这个钱包从没持有过那个代币。这和零余额不同 —— 零余额意味着账户存在且是空的。

wrapped SOL 和我的 SOL 余额是一回事吗? 不是。原生 SOL 是钱包上的 lamport,wrapped SOL 是一个普通代币账户。 它们不同步变动,查其中一个对另一个什么都说明不了。

我明明有 SOL,为什么报 insufficient funds? 因为租金豁免的那部分必须留在账户里,而手续费也从同一份余额里出。可花的额度 = 余额 − 租金豁免 − 手续费余量。

怎么关闭空的代币账户? 每个账户一条关闭指令,租金会退回给 owner。交易很多代币的机器人会持续累积这些账户,所以定期清理能收回真金白银。

余额读取该用哪个 commitment? 据以行动的判断用 confirmedprocessed 更快但可能被回滚,这让它不适合任何你要在链下记录的东西。

从读到余额到我的交易落块,余额会变吗? 会,而且经常变。保护手段是指令内部的链上检查,而不是读之前用一个更严格的 commitment。

怎么把原始金额转成人类可读的值? 除以 10 的 decimals 次方,而且只用于展示。比较和算术保持在原始单位,让精度贯穿整个决策过程。

代币账户里金额存在哪? 在字节偏移 64 处,是一个小端 u64。这就是 readBigUInt64LE(64) 能直接从原始账户数据里解出它的原因。

getTokenAccountBalance 能对钱包地址用吗? 不能。它要的是代币账户地址,不是钱包。 先推导关联代币账户,或者用 getParsedTokenAccountsByOwner 按钱包查。

为什么会有余额为零的代币账户存在? 因为它是为一个后来被全部卖掉的代币创建的。账户会一直留着直到被关闭,期间锁着随时可以取回的租金。

延伸阅读

返回博客列表