理解 Solana——第4部分:Instruction 与 Message

文章围绕 Solana 交易的两层核心结构展开:Instruction 与 Message。

指令 (Instructions)

指令是程序可执行的最小逻辑单元。它由三个主要部分组成:

  • is_signer:如果该账户必须对交易进行签名,则设置为 true
  • is_writable:如果该指令会修改账户数据,则设置为 true
  • pubkey:账户的公钥地址。

数据 (Data)

指令的 data 字段是一个字节序列,用于告诉程序要执行哪个具体函数,并包含该调用所需的参数。

示例:简单的 0.01 SOL 转账

{
  "program_id": "11111111111111111111111111111111",
  "accounts": [
    {
      "pubkey": "6uR7N6oDgE3vJXvM6Eh4xVHw2g7o7YhA7FJxC4pXcZtT",
      "is_signer": true,
      "is_writable": true
    },
    {
      "pubkey": "3Nq8yVbGz7KpYw9sT6rF2LmHc4Qz5XvA1uJd8BvCzRy",
      "is_signer": false,
      "is_writable": true
    }
  ],
  "data": [2, 0, 0, 0, 128, 150, 152, 0, 0, 0, 0, 0]
}

program_id

这是 System Program,Solana 内置的程序,用于管理原生 SOL 转账、创建账户和分配所有权。每个验证节点默认都包含该程序。这告诉运行时:调用 System Program 来执行其某个功能。

accounts

  • 6uR7…ZtT:发送方,必须签名且可被修改(将扣除 lamports)。
  • 3Nq8…zRy:接收方,由于将增加 lamports,因此需要可写,但不需要签名。

data

每种数据变体都编码为 [variant_index:u32][variant_payload]。在本例中:

pub enum SystemInstruction {
    CreateAccount { ... },
    Assign { ... },
    Transfer { lamports: u64 },
    ...
}

Transfer 变体位于索引 2,其负载是一个 u64(8 字节)。 [2, 0, 0, 0, 128, 150, 152, 0, 0, 0, 0, 0] 即:[02 00 00 00 | 80 96 98 00 00 00 00 00] // 4 + 8 = 12 字节

  • [0:4) 字节:类型为 u32,值为 2。在程序源代码中可以看到,TransferSystemInstruction 枚举中位于索引 2。
  • [4:12) 字节:类型为 u64,值为 10,000,000 lamports。
[2, 0, 0, 0]  → discriminant = 2
[128, 150, 152, 0, 0, 0, 0, 0] → 字节转十六进制
[80 96 98 00 00 00 00 00] → 十六进制表示
[0x00000000989680] → 0x00989680 = 10,000,000 lamports (0.01 SOL)

消息 (Messages)

Solana 上的 交易 (transaction) 不仅仅是一组指令;它还是一个由一个或多个账户签名的 消息 (message)。消息精确定义了将要执行的内容、涉及哪些账户,以及哪个最近的 Blockhash 将其锚定到链的当前状态。

Solana 签名的是消息,而不是单独对每条指令签名。这种设计使交易更加紧凑且易于验证:验证节点只需检查签名者是否授权了网络接收到的同一份消息字节。

一旦签名验证通过,运行时就会按顺序执行这些指令,并以原子方式提交所有更改——要么全部成功,要么整笔交易回滚。

  • 消息 (message) 是“将要发生什么”的规范记录。
  • 签名 (signature) 证明了“是谁批准了它”。

Legacy 和版本化消息 (Versioned Messages)

最初,所有 Solana 交易都使用单一的消息格式,现在称为 Legacy 消息。它对于简单转账和小型程序来说效果很好,但为了支持并行执行,每笔交易都必须列出其将要读取或写入的 所有 账户。

由于每个账户地址占 32 字节,消息总大小会很快增长。Solana 为了适配网络的 MTU,强制设定了严格的上限(序列化交易约为 1232 字节)。这使 Legacy 交易最多只能容纳大约 35 个账户,对于复杂、可组合的 DeFi 协议而言就成了问题。

为了解决这一点,Solana 引入了 版本化交易 (versioned transactions),从 v0 消息 开始,并配套引入了一个新的链上程序:地址查找表 (Address Lookup Table, ALT) 程序。查找表允许开发者将一组地址存储在链上,之后通过较小的 1 字节索引来引用,而不必使用完整的 32 字节 Key。

Legacy 消息

pub struct Message {
    pub header: MessageHeader,
    pub account_keys: Vec<Address>,
    pub recent_blockhash: Hash,
    pub instructions: Vec<CompiledInstruction>,
}

pub struct MessageHeader {
    pub num_required_signatures: u8,
    pub num_readonly_signed_accounts: u8,
    pub num_readonly_unsigned_accounts: u8,
}

pub struct CompiledInstruction {
    pub program_id_index: u8,
    pub accounts: Vec<u8>,
    pub data: Vec<u8>,
}

注意: #[serde(with = "short_vec")] 属性对提升序列化效率非常重要。

Legacy 消息大约可以容纳 35 个账户(35 * 32 字节 = 1120 字节,占总 1232 字节中的一部分)。它包含固定的 Header、账户、Blockhash 和指令。

版本化消息 (Versioned Messages)

pub enum VersionedMessage {
    Legacy(LegacyMessage),
    V0(v0::Message),
}

pub struct Message {
  pub header: MessageHeader,
  pub account_keys: Vec<Pubkey>,
  pub recent_blockhash: Hash,
  pub instructions: Vec<CompiledInstruction>,
  /// 用于加载额外账户的地址表查找列表
  #[serde(with = "short_vec")]
  pub address_table_lookups: Vec<MessageAddressTableLookup>,
}

pub struct MessageAddressTableLookup {
  pub account_key: Pubkey,
  #[serde(with = "short_vec")]
  pub writable_indexes: Vec<u8>,
  #[serde(with = "short_vec")]
  pub readonly_indexes: Vec<u8>,
}

V0 引入了 地址查找表 (ALTs),使消息可以通过 1 字节索引 来引用大量账户,而不是内联 32 字节的 pubkey。

地址查找的工作原理

在运行时,验证节点会构建一个统一的 已解析账户列表 (resolved account list),指令会通过索引访问该列表:

resolved_keys = [ 
    message.account_keys, 
    looked_up_writable_keys (from all ALTs, in order), 
    looked_up_readonly_keys (from all ALTs, in order) 
]

每个 MessageAddressTableLookup 都指向一个特定的链上 ALT(account_key)。writable_indexesreadonly_indexes 是该 ALT 所存储地址中的 u8 索引。运行时会取出这些 pubkey,并将它们追加到 resolved_keys 中。

识别 v0 消息

版本化交易在消息编码中使用 前导版本位

  • 如果第一个字节的最高位被 设置,其余位就用于编码 版本号(v0 = 0)。
  • 如果未设置,则会被视为 Legacy 消息。

大小影响

v0 会增加少量额外开销,但在需要许多账户时,能显著减少内联 key 所占的字节数。

每笔交易增加的开销:

  • +1 字节:版本标签
  • +1 字节address_table_lookups 的长度
  • 每个查找表 +34 字节(32 字节用于表 pubkey,+1 字节用于 writable 长度,+1 字节用于 readonly 长度)
  • 每个查找索引 +1 字节

节省的空间:每个通过查找获得的地址,都用 1 字节 索引替换了原本 32 字节 的内联 pubkey。

限制与规则

  • 每个 ALT 最多 256 个条目:索引为 u8(0–255)。
  • 最多 256 个唯一账户:加载的账户总数不能超过 256,因为编译后指令中的账户索引也是 u8
  • 签名者不能来自 ALT:所有签名者 key 都必须出现在 account_keys 中,以便高效验证。
  • 不能重复:同一个账户不能同时在 account_keys 和 ALT 查找结果中被重复加载。
  • ALT 可用性:新追加到 ALT 的条目需要经过一个 slot 后才能使用(预热)。
  • ALT 生命周期:表必须保持租金豁免(rent-exempt),只能追加写入,并且在关闭前有一段冷却期。

理解这些数字

每个 ALT 最多 256 个条目writable_indexesreadonly_indexes 向量中的每个元素都是单字节(范围 0–255)。如果 writable_indexes = [0, 2, 4, 6],运行时就会从该表中加载第 0、2、4 和第 6 个地址。

short_vec 格式:Solana 对变长数组使用 short_vec 格式。其序列化形式为 [长度 (1-9 字节)][元素...]。对于较小的向量(长度 < 128),长度只占 1 个字节

每个查找表 34 字节:一个 MessageAddressTableLookup 需要 32 字节用于 account_key,外加至少 2 字节用于两个索引向量的长度。

MessageHeader

Header 会告诉运行时有多少账户必须签名,以及哪些账户是只读的。

  • num_required_signatures (u8)account_keys 开头有多少个账户必须提供签名。如果该值为 2,则前两个 key 是签名者。
  • num_readonly_signed_accounts (u8):在签名者中,有多少个账户是只读的。这可以防止意外修改签名者账户。
  • num_readonly_unsigned_accounts (u8):在非签名者中,有多少个账户是只读的(例如 System Program、Token Program)。

最近的 Blockhash (Recent Blockhash)

该字段用于防止 重放攻击 (replay attacks),并设置 活性要求。Solana 不使用 nonce;相反,每笔交易都必须包含最近约 150 个区块(约 2 分钟)内的一个 Blockhash。

  • 防重放:如果重新提交一笔已签名交易,而其中的 Blockhash 已经过旧,则该交易会被拒绝。
  • 交易过期:Solana 要求交易必须保持“新鲜”。
  • 分叉承诺:确保交易属于当前分叉。

总结

指令 (instruction) 定义了要执行什么(程序、账户和数据),而 消息 (message) 会将这些指令打包成一个用于签名和验证的原子单元。Legacy 消息适用于大多数场景,而 v0 消息 通过 地址查找表 (Address Lookup Tables),可以用紧凑的 1 字节索引来引用更多账户。

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

0 条评论

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