Solana 存储 Protobuf 定义与构建期代码生成机制解析(storage-proto 模块深度指南)
【免费下载链接】solanaWeb-Scale Blockchain for fast, secure, scalable, decentralized apps and marketplaces.项目地址: https://gitcode.com/GitHub_Trending/so/solana
solana-storage-proto是 Solana 验证者仓库中负责链上数据持久化序列化契约的核心 crate:它以 protobuf 文件为唯一事实来源,在构建时自动生成 Rust 结构体,并通过convert.rs在「运行时交易类型」与「生成的 Protobuf 类型」之间建立双向转换。本文以 storage-proto/README.md 为骨架,结合proto/*.proto定义、src/convert.rs桥接实现与src/lib.rs中的存储结构体,完整讲解其工作原理、三个 Protobuf 数据模型、构建期代码生成配置,以及如何修改 proto 文件以演进链上数据格式。
一、模块定位:构建期自动生成的核心工作流
storage-proto的 README 用两句话界定了整个模块的核心范式:
The
solana-storage-protostructs used insrc/convert.rsand elsewhere are auto-generated from protobuf definitions on build. To update these structs, simply make the desired edits toproto/*.protofiles.
翻译过来就是两条关键约定:
- structs 不是手写的:
convert.rs及仓库其他位置使用的solana-storage-proto结构体,全部是在build 阶段由 protobuf 定义自动生成的; - 升级数据的正确姿势:要更新这些结构体,只需要编辑
proto/*.proto文件,重新构建即可,无需手动同步 Rust 代码。
这种「单一事实来源(Single Source of Truth)」的设计意味着:链上区块、交易、账本条目等数据在写入 Bigtable 等持久化存储时的字节级布局,完全由proto/目录下的三个.proto文件决定。任何格式演进(新增字段、调整字段编号)都必须从这里开始,这与 protobuf 向后兼容的字段编号约束直接相关——已发布的字段编号不得复用或变更类型。
模块的文件结构如下:
storage-proto/ ├── proto/ │ ├── confirmed_block.proto # 已确认区块/交易/状态元数据 │ ├── entries.proto # 账本 Entry(POH 条目)摘要 │ └── transaction_by_addr.proto # 按地址索引的交易记录 ├── src/ │ ├── lib.rs # 存储侧结构体(Stored*)与序列化兼容逻辑 │ └── convert.rs # 运行时类型 <-> 生成类型 的双向转换 ├── Cargo.toml # 构建依赖:tonic-build / protobuf-src └── README.md二、构建期代码生成:tonic-build 与 OUT_DIR 注入
2.1 构建依赖配置
在 storage-proto/Cargo.toml 中,构建依赖(build-dependencies)决定了生成流程:
[build-dependencies] tonic-build = { workspace = true } # windows users should install the protobuf compiler manually and set the PROTOC # envar to point to the installed binary [target."cfg(not(windows))".build-dependencies] protobuf-src = { workspace = true }两个要点:
tonic-build:在build.rs阶段读取proto/*.proto并调用protoc生成 Rust 代码。生成的代码依赖prost运行时(Cargo.toml 中prost = { workspace = true }即为运行时依赖)。protobuf-src(仅非 Windows):为 Unix/Linux 平台编译内置的 protoc 二进制,免去手动安装 protobuf 编译器的麻烦。Windows 用户则需要自行安装 protobuf 编译器,并通过PROTOC环境变量指向安装的二进制,例如:
# Windows 环境变量示例 set PROTOC=C:\path\to\protoc.exe2.2 生成代码如何进入编译单元
生成的代码并不出现在源码目录中,而是写入 cargo 的OUT_DIR,再通过include!宏在编译期嵌入。这在 storage-proto/src/convert.rs 中有明确的证据:
pub mod generated { include!(concat!( env!("OUT_DIR"), "/solana.storage.confirmed_block.rs" )); } pub mod tx_by_addr { include!(concat!( env!("OUT_DIR"), "/solana.storage.transaction_by_addr.rs" )); } pub mod entries { include!(concat!(env!("OUT_DIR"), "/solana.storage.entries.rs")); }env!("OUT_DIR")是 cargo 在构建期注入的环境变量,指向生成文件所在目录。三个 proto 文件各自生成一个 Rust 模块文件,并分别挂载为convert::generated、convert::tx_by_addr、convert::entries三个命名空间。模块名与 proto 文件中的package声明一一对应(详见下文各 proto 文件分析)。
可以推断的完整构建链路为:
proto/*.proto │ tonic-build(build.rs,调用 protoc / protobuf-src) ▼ OUT_DIR/solana.storage.*.rs(生成 Rust 结构体 + impl prost::Message) │ include!(concat!(env!("OUT_DIR"), ...)) ▼ src/convert.rs 中的 generated / tx_by_addr / entries 模块 │ From / TryFrom 桥接 ▼ src/lib.rs 的 Stored* 结构体 与 运行时交易类型三、三个 Protobuf 数据模型详解
3.1confirmed_block.proto:已确认区块的完整画像
storage-proto/proto/confirmed_block.proto 定义了包solana.storage.ConfirmedBlock,是三个 proto 中信息量最大的一个,覆盖从区块头到交易执行细节的全部字段。
顶层ConfirmedBlock聚合了一个区块的核心要素:
| 字段 | 类型 | 编号 | 说明 |
|---|---|---|---|
previous_blockhash | string | 1 | 父区块哈希 |
blockhash | string | 2 | 当前区块哈希 |
parent_slot | uint64 | 3 | 父区块所在的 Slot |
transactions | repeated ConfirmedTransaction | 4 | 区块内全部交易(含状态元数据) |
rewards | repeated Reward | 5 | 区块奖励(费用/租金/质押/投票) |
block_time | UnixTimestamp | 6 | 区块时间戳(int64 timestamp) |
block_height | BlockHeight | 7 | 区块高度(uint64 block_height) |
Transaction/Message/MessageHeader完整刻画交易结构:Transaction由repeated bytes signatures和Message组成;Message包含header(三个 uint32:必需签名数、只读签名账户数、只读非签名账户数)、account_keys、recent_blockhash、instructions,以及两个 Solana 特色字段:
bool versioned = 5; // 是否为版本化交易(v0) repeated MessageAddressTableLookup address_table_lookups = 6; // 地址查找表versioned与address_table_lookups直接对应 Solana 的 Versioned Transaction(v0 消息)与 Address Lookup Table(ALTs)特性,这也是convert.rs中区分VersionedMessage::Legacy与VersionedMessage::V0的依据。
TransactionStatusMeta是交易执行结果的元数据集合,注意其中精心设计的*_none布尔标记:
TransactionError err = 1; uint64 fee = 2; repeated uint64 pre_balances = 3; repeated uint64 post_balances = 4; repeated InnerInstructions inner_instructions = 5; bool inner_instructions_none = 10; repeated string log_messages = 6; bool log_messages_none = 11; repeated TokenBalance pre_token_balances = 7; repeated TokenBalance post_token_balances = 8; repeated Reward rewards = 9; repeated bytes loaded_writable_addresses = 12; repeated bytes loaded_readonly_addresses = 13; ReturnData return_data = 14; bool return_data_none = 15; optional uint64 compute_units_consumed = 16; // 自 v1.10.35 / v1.11.6 起可用*_none标记用于区分「字段为空」与「字段缺失」:protobuf3 的 repeated 字段无法直接表达Option,因此用inner_instructions_none = true表示「原本就是 None」而不是「空列表」,从而在反序列化时还原出正确的Option语义。同理,return_data_none对应Option<TransactionReturnData>,compute_units_consumed使用optional关键字并在注释中注明其版本可用性(v1.10.35 / v1.11.6 之前执行的交易该字段为None)——这是 proto 文件对跨版本兼容性的显式管理。
Reward与RewardType枚举:
enum RewardType { Unspecified = 0; Fee = 1; Rent = 2; Staking = 3; Voting = 4; } message Reward { string pubkey = 1; int64 lamports = 2; uint64 post_balance = 3; RewardType reward_type = 4; string commission = 5; }注意commission在 proto 中是string类型——因为奖励佣金在链上可能是可选数值,用字符串承载可以表达「无佣金」的语义;convert.rs中对应为commission.parse::<u8>().ok()(见 convert.rs),解析失败即得到None。
其他辅助消息包括InnerInstructions/InnerInstruction(含自 v1.14.6 起可用的optional uint32 stack_height)、CompiledInstruction、TokenBalance/UiTokenAmount(ui_amount为 double、amount为最小单位字符串)、ReturnData(program_id+data)、UnixTimestamp与BlockHeight包装器。
3.2entries.proto:PoH 条目摘要
storage-proto/proto/entries.proto 定义了包solana.storage.Entries,用于持久化PoH(Proof of History)账本条目的轻量摘要:
message Entries { repeated Entry entries = 1; } message Entry { uint32 index = 1; // 条目序号 uint64 num_hashes = 2; // 该条目包含的哈希数 bytes hash = 3; // 条目哈希 uint64 num_transactions = 4; // 条目内交易数 uint32 starting_transaction_index = 5; // 起始交易索引 }它与convert.rs中From<(usize, EntrySummary)> for entries::Entry的转换一一对应(convert.rs):(index, EntrySummary)元组被映射为Entry,其中EntrySummary来自solana-transaction-statuscrate,携带num_hashes、hash、num_transactions、starting_transaction_index。反向转换From<entries::Entry> for EntrySummary则用Hash::new(&entry.hash)重建哈希对象。
3.3transaction_by_addr.proto:按地址查询交易的索引模型
storage-proto/proto/transaction_by_addr.proto 定义了包solana.storage.TransactionByAddr,是「按地址反查交易历史」场景的数据模型,也是三个 proto 中错误类型编码最精细的一个:
message TransactionByAddr { repeated TransactionByAddrInfo tx_by_addrs = 1; } message TransactionByAddrInfo { bytes signature = 1; TransactionError err = 2; uint32 index = 3; Memo memo = 4; UnixTimestamp block_time = 5; }错误类型的三层结构是这里的重点:
message TransactionError { TransactionErrorType transaction_error = 1; // 顶层错误类别(37 个枚举值) InstructionError instruction_error = 2; // 指令级错误(含索引 + 错误码 + 自定义错误) TransactionDetails transaction_details = 3; // 携带账户索引的细节(如 DuplicateInstruction) } enum TransactionErrorType { ACCOUNT_IN_USE = 0; ACCOUNT_LOADED_TWICE = 1; ... WOULD_EXCEED_MAX_BLOCK_COST_LIMIT = 17; ADDRESS_LOOKUP_TABLE_NOT_FOUND = 23; ... UNBALANCED_TRANSACTION = 36; }TransactionErrorType枚举完整镜像了solana_sdk::transaction::TransactionError的 37 个变体,从ACCOUNT_IN_USE(0)到UNBALANCED_TRANSACTION(36),涵盖账户、签名、费用、地址查找表、成本限制等各类失败原因。InstructionErrorType枚举则更细,包含 54 个值(GENERIC_ERROR0 到BUILTIN_PROGRAMS_MUST_CONSUME_COMPUTE_UNITS53),对应InstructionError的全部错误码。
convert.rs中这段转换非常值得研究(convert.rs):TryFrom<tx_by_addr::TransactionError> for TransactionError首先特判transaction_error == 8(即INSTRUCTION_ERROR),此时从instruction_error子消息中还原出TransactionError::InstructionError(index, InstructionError)——其中error字段的数值被逐一分派回对应的InstructionError变体,custom字段还原为InstructionError::Custom(custom);随后对transaction_details处理DuplicateInstruction(30)、InsufficientFundsForRent(31)、ProgramExecutionTemporarilyRestricted(35)这三个携带索引的变体;最后才是平铺的其余 34 个无载荷变体。反向From<TransactionError> for tx_by_addr::TransactionError则做完全对称的编码。
四、convert.rs:运行时类型与生成类型的双向桥
4.1 转换矩阵总览
convert.rs的全部职责是打通三层类型系统:
- 运行时类型(
solana-sdk/solana-transaction-status):如VersionedTransaction、TransactionStatusMeta、ConfirmedBlock、Reward、TransactionError等; - 生成类型(本 crate 的
generated/tx_by_addr/entries模块):protobuf 编译产物; - 存储类型(
lib.rs的Stored*):面向 bincode 序列化的中间表示。
关键转换实现及其行为:
| 转换 | 方向 | 说明 |
|---|---|---|
VersionedConfirmedBlock → generated::ConfirmedBlock | From | 区块聚合转换,block_time/block_height包装为Option |
generated::ConfirmedBlock → ConfirmedBlock | TryFrom | 反向转换,错误类型为bincode::Error |
TransactionWithStatusMeta → generated::ConfirmedTransaction | From | 区分MissingMetadata与Complete两种形态 |
VersionedMessage → generated::Message | From | 依据Legacy/V0分别转换,V0 携带address_table_lookups |
generated::Message → VersionedMessage | From | 依据versioned布尔标志还原为Legacy或V0 |
TransactionStatusMeta ↔ generated::TransactionStatusMeta | From/TryFrom | 含*_none标记与loaded_addresses的拆装 |
Reward ↔ generated::Reward | From | RewardType与 protobuf 枚举的数值映射 |
TransactionError ↔ tx_by_addr::TransactionError | From/TryFrom | 三层错误结构的双向编解码(见 3.3) |
(usize, EntrySummary) ↔ entries::Entry | From | 账本条目摘要转换 |
4.2 版本化交易与地址查找表的转换细节
generated::Message的versioned布尔字段决定了还原路径(convert.rs):
impl From<generated::Message> for VersionedMessage { fn from(value: generated::Message) -> Self { // ...header / account_keys / recent_blockhash / instructions 重建 if !value.versioned { Self::Legacy(LegacyMessage { header, account_keys, recent_blockhash, instructions }) } else { Self::V0(v0::Message { header, account_keys, recent_blockhash, instructions, address_table_lookups }) } } }同时MessageAddressTableLookup ↔ generated::MessageAddressTableLookup的转换中,account_key以 Pubkey 的原始字节形式存入bytes字段,writable_indexes/readonly_indexes则直接以字节切片存储。
4.3 Token 余额的转换细节
generated::TokenBalance → TransactionTokenBalance(convert.rs)中有一个值得注意的容错逻辑:ui_amount与 0.0 的差值在浮点精度(f64::EPSILON)范围内时被还原为None;ui_amount_string为空时,会用real_number_string_trimmed(amount, decimals)从最小单位金额重新计算——这保证了旧数据(可能缺少展示字符串)也能正确渲染。
五、lib.rs:存储侧结构体与向后兼容
5.1 为什么需要Stored*结构体
lib.rs定义了四个Stored*结构体(见 storage-proto/src/lib.rs):StoredExtendedReward、StoredTokenAmount、StoredTransactionTokenBalance、StoredTransactionStatusMeta。它们的作用是作为介于运行时类型与生成类型之间的 bincode 友好中间表示,用于旧版 Bigtable 数据路径。
StoredTransactionStatusMeta是其中最重要的结构:
#[derive(Serialize, Deserialize)] pub struct StoredTransactionStatusMeta { pub status: Result<()>, pub fee: u64, pub pre_balances: Vec<u64>, pub post_balances: Vec<u64>, #[serde(deserialize_with = "default_on_eof")] pub inner_instructions: Option<Vec<InnerInstructions>>, // ... log_messages / pre_token_balances / post_token_balances / // rewards / return_data / compute_units_consumed 同构 }5.2default_on_eof:字段级向后兼容的利器
Stored*结构体的每个可选字段都标注了#[serde(deserialize_with = "default_on_eof")]。default_on_eof来自solana_sdk::deserialize_utils(lib.rs),其语义是:当 bincode 反序列化到达 EOF(即旧数据不包含该字段)时,返回None/默认值而不是报错。
这是 Solana 升级存储格式的经典手法:新版本在Stored*中追加可选字段后,旧数据仍可被读取(缺失字段默认为None),实现平滑的格式演进,无需数据迁移。这与 proto 文件中*_none标记、optional字段形成互补——前者保护 bincode 旧路径,后者保护 protobuf 新路径。
5.3 bincode 的边界:loaded_addresses的弃用
TryFrom<TransactionStatusMeta> for StoredTransactionStatusMeta(lib.rs)中有一段显式的弃用守卫:
if !loaded_addresses.is_empty() { // Deprecated bincode serialized status metadata doesn't support // loaded addresses. return Err( bincode::ErrorKind::Custom("Bincode serialization is deprecated".into()).into(), ); }即:旧版 bincode 序列化的状态元数据不支持 loaded addresses(v0 版本化交易的地址查找表加载结果)。一旦发现loaded_addresses非空,直接返回"Bincode serialization is deprecated"错误,引导上层走 protobuf 序列化路径。这清晰展示了新旧两条序列化通道的职责划分与迁移方向。
六、测试验证:往返一致性保障
convert.rs文件末尾内置了完整的单元测试模块(convert.rs,#[cfg(test)] mod test),核心测试模式是往返(round-trip)一致性:
test_reward_type_encode:对None/Fee/Rent/Voting/Staking五种奖励类型逐一验证Reward → generated::Reward → Reward往返后完全相等;test_transaction_by_addr_encode:构造带真实 base58 签名的TransactionByAddrInfo(index: 5、memo: "string"、block_time: 1610674861),验证经 protobuf 往返后相等;test_transaction_error_encode:覆盖AccountBorrowOutstanding、AccountInUse、InstructionError(10, AccountAlreadyInitialized)等大量错误变体(包括三层结构中的InstructionError与TransactionDetails路径),逐一验证TransactionError → tx_by_addr::TransactionError → TransactionError往返相等。
这些测试的工程意义在于:任何对 proto 文件的修改或转换逻辑的调整,都必须保持与运行时类型的双向转换不丢失信息,从而保证 Bigtable 写入的数据可被完整读回。
七、实际消费方:storage-bigtable
solana-storage-proto的主要消费方是 storage-bigtable 模块(storage-bigtable/Cargo.toml、storage-bigtable/src/bigtable.rs、storage-bigtable/src/lib.rs 中均引用了该 crate)。在bigtable.rs的测试代码中可以看到典型用法(bigtable.rs):ConfirmedBlock先转换为StoredConfirmedBlock(bincode 路径),再通过solana_storage_proto::convert::generated转换为generated::ConfirmedBlock(protobuf 路径),随后即可由 prost 编码为字节写入 Bigtable。
由此可以梳理出完整的链上数据持久化链路:
区块/交易确认 │ ▼ solana-transaction-status 的 ConfirmedBlock / TransactionStatusMeta(运行时类型) │ convert.rs 的 From / TryFrom ▼ storage-proto 生成类型(generated::ConfirmedBlock 等) │ prost::Message::encode(protobuf 序列化) ▼ storage-bigtable 写入 Google Cloud Bigtable八、修改 proto 的实践指南
根据 README 的指引与源码证据,扩展存储格式的标准流程为:
- 编辑 proto 文件:在 storage-proto/proto/ 下修改或新增消息字段。务必遵守 protobuf 兼容性铁律:只能新增字段并分配新编号,不得修改既有字段的类型或复用编号;
- 补充转换逻辑:在 storage-proto/src/convert.rs 中为新字段增加
From/TryFrom的映射(注意*_none标记、Option语义的还原); - 同步存储结构:若涉及 bincode 旧路径,在 storage-proto/src/lib.rs 的
Stored*结构体中追加可选字段,并用#[serde(deserialize_with = "default_on_eof")]保持向后兼容; - 构建生成:重新
cargo build,tonic-build会重新生成 Rust 结构体;非 Windows 平台由protobuf-src提供 protoc,Windows 需确保PROTOC环境变量已配置; - 运行往返测试:执行
cargo test -p solana-storage-proto,确保test_*_encode系列测试通过,验证新字段的序列化往返一致。
总结
solana-storage-proto通过「proto 定义 → 构建期自动生成 → 双向转换桥接 → bincode/protobuf 双通道兼容」的设计,将 Solana 链上数据的持久化格式从手写结构体中解放出来。理解这个模块的关键在于把握三条主线:proto/*.proto是数据契约的事实来源,convert.rs是运行时类型与生成类型之间的翻译层,lib.rs的Stored*结构体配合default_on_eof保障了存储格式的平滑演进。无论是为链上数据新增字段,还是排查 Bigtable 数据读写的格式问题,这三个文件都是需要优先审视的入口。
【免费下载链接】solanaWeb-Scale Blockchain for fast, secure, scalable, decentralized apps and marketplaces.项目地址: https://gitcode.com/GitHub_Trending/so/solana
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考