大多数 Solana 开发者通过 SDK 构建交易,从来没看过字节。这没问题,直到某笔交易过大、或者某个账户本不该可写却可写了、或者某个签名校验的对象和你以为的不一样。
★上面每一个问题,只要你知道这个结构长什么样,都是可读的。★
布局
┌──────────────────────────────────┐
│ 签名 ★每个 64 字节★ │
├──────────────────────────────────┤
│ header 3 字节 │
├──────────────────────────────────┤
│ 账户 key ★每个 32 字节★ │
├──────────────────────────────────┤
│ recent blockhash 32 字节 │
├──────────────────────────────────┤
│ 指令 不定长 │
└──────────────────────────────────┘
★总计 ≤ 1232 字节★
签名之后的所有东西合起来是 message —— 而签名覆盖的正是它。改动其中一个字节,已经收集到的每一个签名都作废。
三个字节的 header 决定了一切
字节 0: numRequiredSignatures
字节 1: numReadonlySignedAccounts
字节 2: numReadonlyUnsignedAccounts
线上格式里没有按账户的标志位。 ★你在 JavaScript 里设的那些标志,最终变成了一个排好序的列表里的位置,而这三个计数定义了边界在哪。★
accountKeys = [
★可写的签名者★ ← 索引 0 .. (numRequiredSignatures - numReadonlySigned - 1)
只读的签名者 ← 接下来 numReadonlySignedAccounts 个
★可写的非签名者★ ← 中间这段
只读的非签名者 ← 最后 numReadonlyUnsignedAccounts 个
]
这就是账户顺序不是装饰的原因,也是手续费支付方永远在索引 0 的原因 —— 按构造它就是第一个可写的签名者。
const message = tx.message;
console.log("需要签名数:", message.header.numRequiredSignatures);
message.staticAccountKeys.forEach((k, i) => {
console.log(i, k.toBase58(),
message.isAccountSigner(i) ? "签名者" : "",
message.isAccountWritable(i) ? "可写" : "只读");
});
指令按索引引用账户
指令里不带账户 key。它带的是账户列表里的索引:
programIdIndex 1 字节 ← 哪个账户是程序
accountIndices 每个 1 字节
data 不定长
★这就是重复账户免费的原因。★ 同一个账户在五条指令里被引用,在账户列表里只占一次 32 字节,外加每次引用一个索引字节。
这也是顺序属于接口的一部分的原因。程序按位置接收账户,所以一个错误的索引会静默地把它指向另一个账户 —— 那正是产出"成功却做错事"的交易的那种失败模式。
如果你的交易结构没问题、还是落得晚,免费领个 BoltTx key 改一行就能测测提交路径。
字节到底花在哪
以一笔典型的两跳 swap 为例:
| 组成部分 | 字节 | 占比 |
|---|---|---|
| 1 个签名 | 64 | 5% |
| Header | 3 | — |
| ★30 个账户 key★ | ★960★ | ★78%★ |
| Blockhash | 32 | 3% |
| 指令 | 约 150 | 12% |
| 合计 | 约 1209 | ★贴着上限★ |
★账户 key 占绝对主导,这是本文最有用的一个事实。★ 当一笔交易过大时,答案几乎从来不是"把指令数据缩短" —— 而是减少或压缩账户引用。
const raw = tx.serialize();
console.log(`${raw.length} / 1232`);
console.log("账户数:", message.staticAccountKeys.length,
"=", message.staticAccountKeys.length * 32, "字节");
在假设问题出在别处之前,先跑这个。 如果账户字节超过 800 左右,AddressLookupTableProgram 就是解法;如果占大头的是指令数据,它一点忙都帮不上。
版本化交易改变了什么
v0 交易在指令之后多了一段:
┌──────────────────────────────────┐
│ ... 和 legacy 相同 ... │
├──────────────────────────────────┤
│ ★address table lookups★ │
│ 表地址 32 字节 │
│ 可写索引 ★每个 1 字节★ │
│ 只读索引 ★每个 1 字节★ │
└──────────────────────────────────┘
★通过 lookup table 引用的账户,每个占 1 字节而不是 32 字节。★ 三十个账户内联是 960 字节;走表大约是 30 字节,加上 32 字节的表地址。
版本靠线上的一个前缀字节标示,这就是为什么不带 maxSupportedTransactionVersion: 0 读回一笔 v0 交易会得到 null —— RPC 不会把一种你没声明能解析的格式交给你。
签名覆盖 message,仅此而已
const messageBytes = tx.message.serialize();
// ★每一个签名签的都恰好是这些字节。★
三个值得记住的后果:
相同的字节产出相同的签名。 一个签名最多被收录一次 —— 这就是重发同一笔序列化交易安全、而重建不安全的原因。
任何修改都会作废所有签名。 在用户签完之后加一条 setComputeUnitLimit 指令会破坏他的签名 —— 而报错点名的是他的密钥,不是你的改动。
blockhash 在被签名的 message 里面。 你没法不重新签名就换掉它 —— 这正是 durable nonce 存在的全部理由。
从链上读回一笔交易
const tx = await connection.getTransaction(sig, {
maxSupportedTransactionVersion: 0,
});
console.log("手续费:", tx?.meta?.fee);
console.log("计算:", tx?.meta?.computeUnitsConsumed);
console.log("错误:", tx?.meta?.err);
tx?.meta?.logMessages?.forEach((l) => console.log(l));
★computeUnitsConsumed 是该拿来和你请求的上限比对的那个字段。★ 差距很大意味着你在按一个远高于真实用量的数字支付优先费 —— 计算上限是一个手续费决策,而你正是在这里看见它。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★结构决定一笔交易能不能被发出去;路由决定它什么时候落块。★ 一笔结构完好的交易仍然要到达出块方,而任何字节级的调优都改变不了那一半。
BoltTx 在这里的位置
我们传输你签好的字节,原封不动。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。★我们不修改交易内容 —— 这不只是一条政策,更是结构上的必然,因为任何改动都会作废你的签名。★
你在本地签名。我们不托管资金、不代签。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
Solana 交易的结构是什么样的? 先是签名,然后是一个 message,里面包含三字节 header、账户 key 列表、recent blockhash 和指令。签名覆盖 message,不覆盖别的。
Solana 的交易体积上限为什么是 1232 字节? 它源于扣掉协议开销之后的网络包大小。实际上每个 32 字节的账户 key 吃掉了大部分,所以这个上限卡的是账户数量,不是指令数据。
交易 header 里装的是什么? 三个计数:需要的签名数、只读的签名账户数、只读的非签名账户数。因为线上没有按账户的标志位,它们定义了排序账户列表里的边界。
手续费支付方为什么永远在索引 0? 因为账户列表把可写签名者排在最前,而支付方按定义就是可写签名者。它的位置是排序规则的必然结果。
指令怎么引用账户? 按交易账户列表里的索引,每次引用一个字节。这就是重复账户只占一次 32 字节的原因,也是账户顺序属于程序接口的原因。
一笔交易里什么最占空间? 账户 key,每个 32 字节。对多跳 swap 来说,它们通常占到总量的四分之三左右,这就是 lookup table 专门针对它们的原因。
怎么查我的交易体积?
调 serialize() 读长度,再和 staticAccountKeys.length * 32 比对。账户字节占大头就用 lookup table;指令数据占大头就没用。
版本化交易怎么减小体积? 它多了一段 lookup,账户用指向链上表的一字节索引来引用,而不是完整的 32 字节 key,外加表地址本身的 32 字节。
getTransaction 为什么对我的交易返回 null?
多半是漏了 maxSupportedTransactionVersion: 0。RPC 不会把版本化交易返回给一个没声明能解析它的调用方。
签名到底签的是什么? 序列化后的 message —— header、账户 key、blockhash 和指令。不包括签名本身,这正是多方能各自独立地对同一个 message 签名的原因。
加一条指令为什么会破坏已有签名? 因为指令是被签名 message 的一部分。任何改动都会产出不同的字节,所以先前收集的签名对不上你提交的东西了。
能不重新签名就换 blockhash 吗? 不能。blockhash 在被签名的 message 里面 —— 这正是那些"签名无法按需重生成"的流程需要 durable nonce 的原因。
一笔交易能有多少个签名? 在体积上限之内、每个 64 字节能装下多少就多少。和账户 key 比,签名很少成为瓶颈。
computeUnitsConsumed 有什么用? 拿实际用量和你请求的上限比对。差距大意味着你在按一个虚高的数字付优先费,因为费用是价格乘以请求的上限。
交易数据是加密的吗? 不是。一旦广播,交易内容就是可见的 —— 这就是"落块之前不把交易暴露给公共 mempool 的提交路径"对交易者很重要的原因。
怎么解码一笔原始交易?
legacy 用 Transaction.from,v0 用 VersionedTransaction.deserialize,然后检查 message。这是确认"你实际构建的"和"你打算构建的"是否一致的最快办法。
延伸阅读
- Solana 版本化交易
- Solana 的 account meta
- Solana 的 partialSign 与手续费支付方分离
- Solana compute unit 计价
- Solana 交易上链完全指南