深入理解 Solana(五):交易、序列化、签名、费用与运行时执行

本文深入探讨了 Solana 交易的底层机制。文章首先介绍了交易的结构,即签名数组与消息体的组合,并详细解析了 Ed25519 签名过程如何避免循环依赖。随后,文章阐述了交易费用的构成,包括基础费用和基于计算预算(CU)的优先费。最后,文章梳理了验证者处理交易的完整生命周期:从签名验证、账户加载与锁定,到指令执行及原子性提交。通过这些核心环节,展示了 Solana 如何实现高吞吐量的并行执行模型。

在本系列的上一篇中,我们拆解了 Solana 如何构造 instructionsmessages,以及为什么网络最终超越了 legacy message format。

我们看到,v0 messages 和 Address Lookup Tables 让开发者能够引用远多于最初约 35 个账户限制的账户,同时也说明了 message,而不是 transaction,才是“应该发生什么”的规范性记录。

但理解 message format 只是整个故事的前半部分。

因为一旦你知道了 message 是如何构建的,下一个自然的问题就是:

“当这个 message 在网络上变成一个真实的 transaction 时,究竟会发生什么?”

1. 它是如何被序列化的?

2. wallet 如何对它进行签名?

3. 为什么 blockhash 会过期?

4. fee 从哪里来?

runtime 如何决定哪些 instructions 可以并行运行?

以及为什么 simulation 有时会产生与真实执行不同的结果?

本文会再深入一层。

我们将端到端地跟踪一个 transaction,从 message 被组装的那一刻开始,经过签名验证、账户加载和 runtime 执行,一直到原子性的状态提交。

什么是 Solana Transaction?

要与 Solana 交互,你需要提交一个 transaction。正如我们在上一篇文章中讨论的,transaction 并不直接持有原始 instructions,而是持有一个 message,它定义了将运行哪些 instructions、它们需要哪些账户,以及哪个 recent blockhash 将 transaction 锚定到当前链状态。

该 message 中的每个 instruction 都代表网络应执行的一项特定操作。在下面的示例中,第一个 transaction 包含一个带有单个 instruction 的 message,而第二个则包含一个带有三个 instructions 的 message,它们将按顺序执行:instruction 1,然后 instruction 2,最后 instruction 3。

transaction 对象看起来是这样的:

  • signatures:签名数组
  • message:transaction 信息,包括待处理的 instruction 列表
pub struct Transaction {
    #[serde(with = “short_vec”)]
    pub signatures: Vec<Signature>,
    pub message: Message,
}

Transaction 的总大小限制为 1232 字节。这个限制同时包括 signatures 数组和 message struct。

签名

transaction 持有一个由 64 字节签名组成的数组。

其中每一个签名,都是由某个作为必需 signer 出现的账户,使用其私钥对 transaction 的 Message 进行签名后生成的。任何 instruction 所引用的每个 signer 都必须在这个数组中提供一个对应的签名。

第一个签名始终来自 fee-payer。这也是 transaction 的主签名,也就是你在 explorer 和 RPC endpoint 上查询 transaction 时使用的那个签名。

signature struct 看起来 是这样 ](https://github.com/anza-xyz/agave/blob/v2.1.13/compute-budget/src/compute_budget_limits.rs#L10) 以及每个 transaction 最多 140 万个 compute units。** ](https://github.com/solana-labs/solana/blob/ca115594ff61086d67b4fec8977f5762e526a457/program-runtime/src/compute_budget.rs#L207-L209)

示例:让我们来看一下这个 transaction

我们可以看到,这里的 Priority fee 是 0.000004 SOL,总 fee 是 0.000009 SOL。

让我们来理解它是如何计算的:

正如我们之前所说,默认情况下,Solana 为每个 instruction 分配 200,000 个 compute units。

在这个 transaction 中有 2 个 instructions,但如前所述,

ComputeBudget

instructions 不计入计算,因此最终只剩下 1 个 instruction,以及 20000 micro-lamports 的价格,也就是 0.02 lamports/CU。

所以价格将是:200,000 * 0.02 = 4000 lamports,也就是 0.000004 SOL。

记住我以便更快登录

此外,在 transfer instruction 中还有 1 个 transaction signer,其成本是 0.000005 SOL。

Total fee = BaseFee + PriorityFee =  0.000004 + 0.000005 = 0.000009

Wallet 实际上是如何签名的

一个常见的困惑点是,当 message 本身已经包含该 signer 的账户时,signer 怎么还能对这个 message 进行签名。这一开始看起来像是循环依赖:wallet 怎么能在 signer 生成签名之前,就把 signer 包含进 message 中?

关键在于理解:message 包含的是 signer 的公钥,而不是他们的签名

签名是在 message 最终确定之后才被添加的。

实际流程如下:

1. wallet 先构建完整的 message

在任何签名存在之前,wallet 会以最终形式构造 message:

  • message header(signer 数量);
  • 所有账户公钥;
  • recent blockhash;
  • 编译后的 instructions。

在这个阶段,message 已经包含了 signer 的公钥,因为 runtime 需要知道:

  • 哪些账户必须签名;
  • signer 账户的顺序;
  • 哪个签名对应哪个 key。

此时还没有任何签名。

message 只是一个字节数组,精确描述了将要执行的内容。

2. signer 对 message bytes 进行签名

一旦 message 被完整构造出来,wallet 就会对精确序列化后的 message bytes进行签名:

signature = Ed25519_sign(private_key, message_bytes)

对 message 的任何改动,无论多么小,都会使签名失效。

因为 message 只包含公钥,所以对它签名不存在循环依赖。

这就像在一份列有你姓名的法律文件上签字:

你的名字是文档的一部分,但你的签名是在之后添加的。

3. 签名被插入到 transaction 中

签名完成后,它会根据 signer 在 account_keys 中的位置,被放入 transaction 的签名数组中:

  • 索引 0 处的签名 → 第一个 signer(fee-payer)
  • 索引 1 处的签名 → 第二个 signer
  • 依此类推。

签名不会修改 message。

message 现在已经锁定,修改它会破坏所有签名。

Transaction 会将所有这些数据打包进它的结构中,并把构建好的 transaction 发送给节点。

概览

  • Message 从不包含签名,只包含公钥。
  • 签名是基于最终 message 创建的,而不是之前。
  • 签名存在于 message 之外,位于 transaction 包装层中。

这种分离带来了:

  • 确定性的签名;
  • 规范化验证;
  • 简单的重放保护;
  • 以及轻量级 transaction。

这里不存在循环依赖,signer 对 message 进行签名,而 message 只是标识 signer 是谁。

Solana 如何执行一个 Transaction

到目前为止,我们主要关注的是 transaction 如何被构造、message 如何形成、签名如何生成、fee 如何计算,以及 wallet 如何将所有内容打包在一起。

一旦 validator 收到一个 transaction,Solana 会通过一系列步骤来处理它,以确保该 transaction 有效、能够安全地并行运行,并且要么完全成功,要么完全失败。

1. 签名验证

  • validator 会对 message 进行哈希。
  • 每个签名都会根据其对应的公钥进行检查。
  • 任何无效签名 → transaction 会被立即拒绝。

2. 账户加载

  • message 中列出的所有账户(以及 ALT,如果使用了)都会被获取。
  • runtime 会检查:
  1. 账户是否存在
  2. 所有权规则
  3. signer 标志
  4. 读/写权限
  • 如果有任何内容无效 → transaction 会在这里停止。

3. 账户锁定

  • 可写账户会获得一个写锁
  • 只读账户会获得一个读锁
  • 这些锁可确保不存在冲突的 transaction 并行执行。
  • 如果所需账户已经被锁定 → transaction 可能会被延迟或跳过。

4. Instruction 执行

  • instructions 会按顺序逐个运行。
  • 每个 instruction:
  1. 调用目标 program
  2. 更新 compute 使用量
  3. 可能执行 CPI
  • 如果任何一个 instruction 失败 → 整个 transaction 失败。

5. Compute Budget 强制执行

  • runtime 会跟踪已消耗的 compute units。
  • 如果超出 CU 限制:
  1. 执行停止
  2. transaction 失败
  3. fee-payer 仍然需要支付 fee

6. 原子提交或回滚

  • 如果所有 instructions 都成功:
  1. 账户变更会被原子性提交
  2. 锁会被释放
  • 如果任何一个 instruction 失败:
  1. 不会应用任何状态变更
  2. transaction 会被记录为失败
  3. fee 仍然会被收取

总结

一个 Solana transaction 不仅仅是一组已签名的 instructions。它是一个结构化请求,会经过一条精确的执行流水线。message 定义了应该发生什么,签名授权了是谁批准了它,而 runtime 则确保一切都有效、能够安全并行运行,并且完全具有原子性。

你已经看到 fee 是如何计算的,compute budget 如何影响优先级,wallet 如何对 message 进行签名,以及 validator 如何从签名检查一路处理 transaction 到最终提交。这些部分共同构成了 Solana 高吞吐、并行执行模型的基础。

理解这一流程,能让你具备推理 transaction 行为、调试失败情况,以及编写能够可靠且高效地与网络交互的 program 所需的工具。

  • 原文链接: medium.com/@andrey_obruc...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Andrey Obruchkov
Andrey Obruchkov
江湖只有他的大名,没有他的介绍。