读一个代币余额看起来是个已经解决的问题。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?
据以行动的判断用 confirmed。processed 更快但可能被回滚,这让它不适合任何你要在链下记录的东西。
从读到余额到我的交易落块,余额会变吗? 会,而且经常变。保护手段是指令内部的链上检查,而不是读之前用一个更严格的 commitment。
怎么把原始金额转成人类可读的值? 除以 10 的 decimals 次方,而且只用于展示。比较和算术保持在原始单位,让精度贯穿整个决策过程。
代币账户里金额存在哪?
在字节偏移 64 处,是一个小端 u64。这就是 readBigUInt64LE(64) 能直接从原始账户数据里解出它的原因。
getTokenAccountBalance 能对钱包地址用吗?
不能。它要的是代币账户地址,不是钱包。 先推导关联代币账户,或者用 getParsedTokenAccountsByOwner 按钱包查。
为什么会有余额为零的代币账户存在? 因为它是为一个后来被全部卖掉的代币创建的。账户会一直留着直到被关闭,期间锁着随时可以取回的租金。
延伸阅读
- Solana 账户数据解析
- Solana 关联代币账户
- Solana 账户租金豁免
- Solana getProgramAccounts 指南
- Solana 交易上链完全指南