Solana 上的一个账户就是一个字节数组。拥有它的那个程序知道这些字节是什么意思,你的客户端不知道 —— 除非你告诉它。
★大多数解析 bug 不是崩溃。它们是一些看起来很合理、但就是错的数字。★
先看 RPC 有没有已经帮你解析好
在自己写解码器之前,先看看是不是已经有一个。只要用的是标准程序,那就有:
const info = await connection.getParsedAccountInfo(tokenAccount);
const parsed = info.value?.data;
if (parsed && "parsed" in parsed) {
console.log(parsed.parsed.info.owner);
console.log(parsed.parsed.info.tokenAmount.amount); // ★字符串★
}
★getParsedAccountInfo 原生支持 SPL 代币账户、mint、质押账户和 nonce 账户。★ 对这些,自己手写解码器是白白增加风险。
它帮不上自定义程序 —— 后文讲的就是那种情况。
代币账户的布局
值得背下来,因为它几乎出现在每一个过滤器和解码器里:
偏移 0 mint 32 字节
偏移 32 ★owner★ 32 字节
偏移 64 ★amount★ 8 字节 (u64,小端)
偏移 72 delegate option 36 字节
偏移 108 state 1 字节
偏移 109 isNative option 12 字节
偏移 121 delegatedAmount 8 字节
偏移 129 closeAuthority 36 字节
★共 165 字节★
const data = info.value!.data as Buffer;
const mint = new PublicKey(data.subarray(0, 32));
const owner = new PublicKey(data.subarray(32, 64));
const amount = data.readBigUInt64LE(64); // ★BigInt,不是 number★
★用 readBigUInt64LE,绝不要对低位一半用 readUInt32LE。★ 一个 u64 能装下远超 JavaScript number 精确表示范围的值,而只读 4 个字节,会对任何大额余额给出一个看起来合理的错误答案。
如果你的解析没问题、问题在交易落不了块,免费领个 BoltTx key 改一行就能测测提交路径。
Anchor 的 discriminator
每个 Anchor 账户开头都有 8 个标识账户类型的字节 —— 是 account:<名字> 的 SHA-256 哈希的前 8 字节。
// ★读任何字段之前先跳过它。★
const body = data.subarray(8);
const authority = new PublicKey(body.subarray(0, 32));
由此推出两件事,而且都很重要:
每个偏移都要挪 8。 结构体里位于字节 0 的字段,在账户里位于字节 8。★这是"memcmp 过滤器匹配不到任何东西"和"解码器返回乱码"最常见的单一原因。★
你可以用它来识别类型。 按 discriminator 过滤,能从一个程序里精确选出某一种账户类型:
const disc = createHash("sha256")
.update("account:PoolState")
.digest()
.subarray(0, 8);
const accounts = await connection.getProgramAccounts(PROGRAM_ID, {
filters: [{ memcmp: { offset: 0, bytes: bs58.encode(disc) } }],
});
用 IDL,不要用偏移量
硬编码的偏移在程序升级之前都是对的,升级之后就静默错了。
import { BorshAccountsCoder } from "@coral-xyz/anchor";
const coder = new BorshAccountsCoder(idl);
const decoded = coder.decode("PoolState", data); // ★布局来自 IDL★
★一次插入了字段的程序升级,会把它之后的每一个偏移全部挪位。★ 你硬编码的解码器照样在跑、照样在返回数字,而每一个数字现在读的都是错的字节。 discriminator 检查能抓到类型不匹配;但同一类型内部插入字段,不会产生任何信号。
基于 IDL 的解码器则会大声失败,因为它用的布局来自程序本身,而不是来自你对它的记忆。
变长字段
Borsh 用一个长度前缀来编码动态数据,这意味着这类字段之后的偏移不是固定的:
u32 长度(4 字节)
然后是那么多字节
let cursor = 8; // 跳过 discriminator
const nameLen = data.readUInt32LE(cursor);
cursor += 4;
const name = data.subarray(cursor, cursor + nameLen).toString("utf8");
cursor += nameLen; // ★下一个字段从这里开始★
★一旦结构体里含有字符串或数组,你就必须顺序地走完它。★ 你没法提前算出后面某个字段的偏移,也没法对它之后的任何东西做 memcmp 过滤。
Option<T> 类似:一个字节表示有没有,有的话再跟值。 一个 Option<Pubkey> 在有值时是 1 + 32 = 33 字节、没值时 1 字节 —— 除非布局给它做了填充,代币账户里 36 字节的 delegate 字段就是填充过的形式。
安全地读数字
// ★正确。★
const amount: bigint = data.readBigUInt64LE(64);
// ★超过 2^53 会丢精度。★
const wrong = Number(data.readBigUInt64LE(64));
在任何比较或算术里都保持 bigint,只在最边缘转成展示值:
const ui = Number(amount) / 10 ** decimals; // 仅用于展示
★用原始单位做比较,不要用展示值。★ 一个在已经丢了精度的浮点数上做的阈值判断,只会在大额余额上暴露出来 —— 而那恰恰是最该算对的那些。
调试解码器
console.log("长度:", data.length);
console.log("owner:", info.value?.owner.toBase58());
console.log("前 16 字节:", data.subarray(0, 16).toString("hex"));
★owner 和 length 放在一起,比任何别的办法都更快地识别出账户类型。★ owner 不对说明你取错了账户。长度出乎意料说明它不是你以为的那个类型 —— 而长度恰好 165,不管你以为在读什么,它就是一个代币账户。
先拿一个你能在浏览器里核对数值的账户验证解码器,再去信任它读那些你没法核对的账户。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★解析是你拿读到的状态做的事;落块是你写出去的状态会遭遇的事。★ 一个能正确读出池子的解码器,仍然需要由此产生的交易到达出块方。
BoltTx 在这里的位置
我们负责提交,不做索引也不做解码。你读账户用什么,保持原样。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。
你在本地签名。我们不托管资金、不代签、不修改交易内容。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const sender = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
怎么解析 Solana 账户数据?
标准程序用 getParsedAccountInfo,它原生解码代币账户、mint、质押和 nonce 账户。自定义程序用该程序的 IDL 解码,而不是硬编码偏移。
Anchor 的 discriminator 是什么? 每个 Anchor 账户开头的 8 个字节,由账户名的哈希得来。它标识类型,并让每个字段的偏移相对结构体挪 8。
我的解码器为什么返回乱码? 最常见的是漏了跳过 discriminator,于是每个字段都早读了 8 个字节。先看账户长度和 owner —— 它们比检查数据更快地识别出类型。
JavaScript 里怎么读 u64 金额?
在字段偏移处用 readBigUInt64LE,并保持 bigint。转成 number 在超过 2^53 时会丢精度,静默地损坏大额余额。
SPL 代币账户的布局是什么? 偏移 0 是 mint,32 是 owner,64 是小端 u64 的 amount,然后是 delegate、state 和其余字段,共 165 字节。这个大小本身就是一个可靠的类型判据。
程序升级后我的偏移为什么就错了? 因为插入一个字段会把它之后的一切挪位。硬编码的解码器照样跑、照样返回错值 —— 这就是从 IDL 解码比背偏移安全的原因。
Borsh 怎么编码字符串和数组? 用一个四字节小端长度前缀,后面跟数据。变长字段之后的任何字段都没有固定偏移,所以你必须顺序地走 buffer。
能对字符串之后的字段做 memcmp 过滤吗? 不能。它的位置取决于那个字符串的长度,没有固定偏移可供过滤。只有第一个变长字段之前的字段能这样过滤。
Borsh 里 Option 怎么编码? 一个字节表示有没有,有的话再跟值。有些布局会把它填充到固定宽度 —— 这就是代币账户的 delegate 占 36 字节而不是 33 字节的原因。
怎么识别一个账户是什么类型? 先看 owner 和数据长度,如果程序用 Anchor 再看 discriminator。长度恰好 165、owner 是代币程序,那就是一个代币账户。
该用 getParsedAccountInfo 还是自己解码? 标准程序用解析好的版本 —— 它有人维护而且是对的。只对自定义程序自己解码,而且那里也优先用 IDL 而不是手写偏移。
我的代币余额为什么不对?
多半是把 u64 转成 JavaScript number 丢了精度,或者八字节的 amount 只读了四个字节。展示之前一直保持 bigint。
怎么把原始金额转成展示值? 除以 10 的 mint decimals 次方,而且只用于展示。比较和算术要在原始单位上做,免得在做决定之前精度就丢了。
怎么验证解码器是对的? 拿一个你能在浏览器里独立核对数值的账户测它。"解码器产出了看起来合理的数字"不能证明偏移是对的。
账户的 owner 字段告诉我什么? 哪个程序控制这个账户,而这决定了数据该怎么解释。owner 出乎意料意味着你取错了账户,而不是解码器坏了。
没有 IDL 能解析账户吗? 能,只要你知道布局,但你同时承担了"程序升级把布局挪位"的风险。有 IDL 的话,布局来自程序本身,而不是来自你对它的假设。
延伸阅读
- Solana getProgramAccounts 指南
- Solana 代币余额查询
- Solana 关联代币账户
- Solana 账户租金豁免
- Solana 交易上链完全指南