Bisq 规范化编码(Canonical Encoding)设计:Protobuf 兼容的确定性哈希前像
本文解读 Bisq 的规范化编码设计文档protobuf-compatible-deterministic-preimage.md,并对照仓库中bisq.common.encoding.canonical包的实际实现。你将理解为什么 Bisq 要在 protobuf 之上新建一层"规范字节"编码器、CanonicalSchema与CanonicalWriter各自的职责边界、针对 protobuf 可变性(malleability)的逐条消解规则,以及 DAO 状态哈希链、P2P 保护存储键、开发者 Filter 签名等真实共识敏感路径如何以 schema 方式落地。读完后,你应能为 Bisq 的任一模型对象写出确定性的 canonical 字节编码并理解其字节级等价性测试策略。
一、问题背景:为什么 protobuf 序列化不能直接用于哈希前像
Bisq 需要为两类共识/安全敏感场景提供规范字节(canonical bytes):
- 签名前像(signature preimage):不同节点对同一逻辑对象必须产生完全相同的字节,否则签名无法跨节点验证;
- DAO 状态监控哈希链:节点用带版本号的 canonical schema 编码同一 DAO 状态转换或哈希链输入并比对哈希,哈希不一致应当指向语义或编码分歧,而非 protobuf 序列化行为的偶然差异。
设计文档开宗明义:canonical 编码器不得依赖任何 protobuf 实现的序列化行为。protobuf-java的版本变化、生成消息代码的变化、map 迭代顺序、unknown field 处理方式等任何实现细节,都不允许改变 canonical 字节。
同时,Bisq 要避免编码规则的"双重事实来源":由 Java 定义的CanonicalSchema描述字段、protobuf 兼容类型、省略规则、嵌套 schema 与顺序规则,通用编码器只负责执行 schema。这样 schema 定义与可执行实现保持在同一处,而不是维护可能各自漂移的"散文描述 + descriptor + 手写 writer"三份定义。
为什么选择 Protobuf 兼容而不是自造字节格式
Bisq 可以选用任何 canonical 字节格式,但选择 protobuf 兼容格式的理由是:
- Bisq 已经在持久化、网络与哈希数据路径中使用
proto3消息,当前 Java protobuf 输出是既有前像的兼容性基线; - 可以复用现有字段号与 protobuf 类型选择;
- 在需要的地方可以编写字节级等价测试,证明与当前 Java protobuf 输出一致;
- 现有贡献者已经熟悉这套数据模型形状;
- 激活可以按 schema 版本逐步进行,而不必整体替换序列化栈。
但必须强调:兼容格式不是"任何 protobuf 解析器都接受的字节"。Bisq 只需要其 canonical schema 用到的 protobuf 特性子集,且每个被支持的特性都必须有显式规则来消除 protobuf 的可变性。结果是:一个受限的 protobuf 兼容子集——在兼容性要求处匹配当前protobuf-java的输出行为,同时拒绝或不予支持会为同一逻辑值引入备选编码的特性。
范围界定:无解码器、不是通用 protobuf 规范
- 这不是完整的 protobuf encoder/decoder,也不是通用 canonical protobuf 规范,它只覆盖 Bisq canonical 前像所需特性;
- 刻意不包含解码器。所需操作只有 object-to-canonical-bytes(用于哈希、监控与签名)。解码任意 protobuf 兼容字节会引入另一组 canonical 校验问题(重复字段、未知字段、非最小 varint、merge 行为),只有在出现具体的 Bisq 需求时才另行设计;
- 编码器是"通用"的,但含义是:任意受支持的 Bisq 模型都可以写一个 schema,而不是接受任意 protobuf descriptor 或任意解析后的 protobuf 消息。canonical 字节必须来自语义已知给 schema 的 Bisq 模型对象;
- schema 版本号只是选择 canonical 规则的元数据,除非某个 schema 显式地在被序列化的对象图中定义了版本字段,否则不会作为额外字节输出。
二、核心概念:CanonicalSchema 与 CanonicalWriter 的分工
设计文档将编码器分为两部分,仓库中对应 CanonicalSchema.java、CanonicalWriter.java 与 CanonicalEncoder.java:
| 组件 | 职责 |
|---|---|
CanonicalSchema<T> | 描述"编码什么":消息名、schema 版本、字段、protobuf 类型映射、取值 accessor、省略规则、嵌套 schema、顺序规则。schema 是事实来源。 |
CanonicalWriter | 描述"如何变成字节":protobuf tag、varint、length-delimited 值、嵌套消息字节、repeated/map entry 输出。 |
CanonicalEncoder | 执行者:遍历 schema 字段并调用 writer。见 CanonicalEncoder.encode。 |
模型对象通过实现 Canonical 接口 接入:
public interface Canonical { byte[] encodeCanonical(CanonicalEncoder canonicalEncoder); default byte[] encodeCanonical() { return encodeCanonical(CanonicalEncoder.DEFAULT); } }注意:序列化应当遍历 schema 字段,而不是维护一个并行的手写 writer;人可读文档可以总结 schema,但不得成为第二事实来源。
从源码结构看:Schema 构建器如何强制"单一事实来源"
CanonicalSchema 的实现印证了文档中"字段按编号升序、类型显式声明"的规则。构建器支持的字段类型(FieldType)与设计文档中的支持子集一一对应:
public enum FieldType { INT32, UINT32, INT64, DOUBLE, BOOL, ENUM, STRING, BYTES, COMPOSE, EXTEND, ONEOF, REPEATED_COMPOSE, PACKED_REPEATED_INT32, REPEATED_STRING, MAP }构建器在add(...)中执行多项结构校验(CanonicalSchema.java):
- 字段号必须升序声明:
number <= previousFieldNumber直接抛IllegalArgumentException("Canonical fields must be declared in ascending field-number order")。这直接落实了 malleability 表中"keep order in CanonicalSchema; constructor rejects non-ascending singular field declarations"的要求; - 字段号必须落在 protobuf 有效范围:
1..(1<<29)-1(即1..536870911),且避开 protobuf 保留区19000..19999; - map 字段必须携带
MapEncoding(含 key/value 类型、映射函数、显式 entry 迭代器),message 值还必须声明嵌套 schema;反之非 map 字段不允许携带 map encoding; - compose / extend / oneof / repeatedCompose 字段必须恰好声明一个嵌套 schema(
CanonicalSchema或Builder二选一);标量字段则不允许嵌套 schema; - 省略规则以
Rule枚举内联在字段类型上:标量默认OMIT_DEFAULT,字符串/字节OMIT_EMPTY,嵌套对象OMIT_NULL,repeatedLIST_ORDER,mapMAP_ORDER。
也就是说,文档中"schema 定义与执行实现绑定"并非口号——顺序违规、类型违规、map 缺迭代器都会在构建期失败,而不是在编码期悄悄产生错误字节。
三、为什么选择 Protobuf 兼容:兼容性基线而非解析器宽容度
这一节的设计立场在文档中反复强调,值得单独展开:
- 既有前像(DAO 状态、提案、盲投等)历史上由
protobuf-java生成,任何替换编码都必须与这些既有字节逐字节兼容,否则哈希链断裂、P2P 存储键失效、签名全部失效; - 因此 Bisq 的 canonical 子集"在需要兼容性处与
protobuf-java输出一致",并通过字节级 parity 测试锁定; - 凡是 protobuf 规范允许但
protobuf-java不会输出、或跨语言行为不一致的特性(非最小 varint、重复字段、unknown field、packed/unpacked 双形态等),canonical 编码器一律不产出,从而把"同一逻辑值的多种合法编码"压缩为唯一一种。
设计文档引用了 Cosmos SDK ADR-027(确定性 protobuf 序列化)作为行业参照:protobuf 的 varint 解码器存在 70 位数值域、packed 与 unpacked 双形态、字段顺序无关性、默认值 presence 等多重可变性来源。Bisq 的做法与 ADR-027 同源,但有 Bisq 自己的范围裁剪(见下文规则表)。
四、Map 顺序问题与 Legacy HashMap 兼容
这是整个设计中工程难度最高、也是 Bisq 特有的部分。文档原文的立场:
- protobuf map 字段编码为 repeated entry message(key 字段
1、value 字段2),wire format 本身不使 map 顺序成为规范; - Java 普通 map 迭代也不提供可移植的规范顺序;legacy Bisq 路径可能依赖
HashMap迭代行为,但不能把它当作通用规则; - 因此 Bisq 用两个显式模式处理,且每个 schema 必须显式选择:
| 迭代器 | 语义 | 适用场景 |
|---|---|---|
LegacyCollectorsToMapIterator | 复刻 Java 11 中Collectors.toMap(...)默认HashMap的迭代顺序 | legacy schema 的兼容模式:旧路径先把条目收集进HashMap再拷入 protobuf map 字段,canonical 字节必须与旧哈希链逐字节一致。属于按 schema 文档化的兼容模式,不是通用的 legacy HashMap 顺序 |
TreeMapIterator | 按键排序输出 entry | 能在已知激活区块或 schema 版本切换到规范 map 顺序的 schema |
源码印证:TreeMapIterator.java 提供naturalOrder()与comparing(Comparator)两个工厂,重复键直接抛IllegalArgumentException("Duplicate canonical map key ..."),即文档所说"重复 map 键视为非规范"。
LegacyCollectorsToMapIterator.java 的实现更见深度:它内部复刻了 OpenJDK 11HashMap的桶表结构(初始容量 16、负载因子 0.75、桶长度 8 时树化、容量 64 以下树化失败则扩容、扩容时按hash & oldCapacity位拆分桶、红黑树插入/旋转等),目的是把"Java 11 HashMap 对这些 key 的迭代顺序"变成平台无关的确定性函数。其 Javadoc 明确限定用途:
"This is only a compatibility replacement for the legacy code paths that collected stream entries with
Collectors.toMap(...)and then copied the resulting HashMap into protobuf map fields. It should not be used as a general replacement for arbitrary old HashMap, putAll, map-copy-constructor, protobuf-map, or source-iteration ordering."
这一约束的意义在于:不同 JDK 版本的HashMap行为细节可能不同,而 DAO 状态哈希链要求所有节点、任何 JDK都算出同样的哈希,所以必须把顺序算法"冻结"在代码里,而不是信任运行环境的HashMap。
此外,CanonicalMapEntryByteCache接口允许重复出现的 map(例如 DAO 状态增量编码)缓存已编码 entry 字节,减少重复编码开销——这是实现层细节,不改变输出字节。
五、ADR-027 可变性覆盖表
设计文档逐条对照 Cosmos SDK ADR-027 指出的 protobuf 可变性来源,并给出 Bisq 的 canonical 规则。下表完整继承原文档:
| ADR-027 问题 | Bisq 规范化规则 |
|---|---|
| 字段顺序、重复的单数字段、额外字段、额外数据 | schema 按字段号升序输出已知字段,单数字段最多输出一次,不输出未知字段或尾部数据。 |
| 默认值 presence | 对 implicit-presence 的 proto3 字段,默认标量值与空字符串/空字节被省略,除非 schema 显式定义 presence。 |
protobufuint70解码域带来的 varint 高位可变性 | writer 使用按字段类型分派的写方法,对选定字段类型输出最短合法 varint,从不暴露通用uint70writer。负数int32/int64保持 protobuf 标准的 10 字节补码编码。 |
| Boolean varint | 单数字段 implicit-presence 的false被省略;输出的 boolean 仅在显式 presence 要求时写0,true写1。 |
| packed 与非 packed 的 repeated 数字字段 | schema 支持 packed repeatedint32,按列表顺序输出恰好一个 proto3 packed 段。其他 packed 数字元素类型在 Bisq schema 需要前保持不支持。 |
| Maps | ADR-027 版本 1 拒绝 map。Bisq 仅在 schema 声明显式 entry 顺序模式时支持 map,并把重复映射键视为非规范。 |
六、通用规则(General Rules)
文档给出的八条全局规则,逐条在实现中可验证:
- schema 是事实来源:字段号、字段名、字段类型、省略规则、嵌套 compose/extend schema、顺序规则都由 schema 决定,不允许临时 writer 代码;
- 字段按字段号升序输出,除非 schema 显式定义了 repeated/map 顺序规则;
- canonical 前像字节中从不输出未知字段;
- 从不输出原始额外字段或尾部字节;
- 单数字段最多输出一次;
- 所有 tag、长度前缀与整数值使用所选字段类型的最短合法 varint,例外是负数
int32/int64使用 protobuf 标准 10 字节补码 varint; - 嵌套消息以 length-delimited 形式输出,内容是其自身 schema产生的 canonical 字节;
- map 字段必须声明显式顺序模式。
Writer 层实现细节对照
CanonicalWriter 是手写的、不依赖com.google.protobuf的字节输出器,只使用三种 wire type(varint=0、fixed64=1、length-delimited=2)。几条关键实现与规则逐条对应:
- tag 编码(CanonicalWriter.java):
private void writeTag(int fieldNumber, int wireType) { writeVarint32((fieldNumber << 3) | wireType); }tag 以最短形式 varint 输出,且类中没有暴露原始 tag 字节的 API(只有writeRawBytes用于拼接已编码的 map 字节,由编码器内部调用);
- 省略默认值:公开写方法在值为默认时不写任何字节——
writeInt32/writeUInt32/writeInt64对0跳过,writeDouble对Double.doubleToRawLongBits(value) == 0(正零)跳过且保留-0.0,writeBool对false跳过,writeString/writeBytes对空串/空字节跳过,writeEnum对 code0跳过; - 负数 int32 的 10 字节编码(writeInt32NoTag):非负走 32 位 varint(最短形式),负数先符号扩展为 64 位再走
writeVarint64,恰好产生 protobuf 标准 10 字节补码形式; - packed repeated int32(writePackedRepeatedInt32):把每个元素按
writeInt32NoTag(含零值,因为 packed 段内元素不省略)写入临时 writer,再整体作为一个 length-delimited 段输出——恰好一个 packed 段,绝不多段; - double 输出为 fixed64 小端原始位:
Double.doubleToRawLongBits保证 NaN 的位模式、+0.0/-0.0的区分都逐字节确定; - repeated string 保留空元素:
writeRepeatedString对列表中的每个元素(包括零长元素)各写一个 length-delimited 字段,不会误删。
七、逐类型 Malleability 表
下表完整继承设计文档的逐类型分析:Bisq 采用的 Java protobuf 行为、其他 protobuf 实现或手工字节可能引入的备选编码、Bisq canonical 的限制,以及"Java 行为不够清晰时"的补充动作。
| 字段类型/结构 | Bisq 采用的 Java protobuf 行为 | 其他 protobuf 允许或跨语言变体 | Bisq 规范化限制 | Java 行为不够清晰时 |
|---|---|---|---|---|
| 字段 tag | Java 以(field_number << 3) \| wire_type写 varint。 | 手工字节可能出现同一 tag 的非最小 varint 编码。 | 只输出最短形式 tag varint。拒绝或永不构造非最小 tag 编码。 | 增加多字节 tag 边界的底层 writer 测试,且不提供原始 tag 字节 API。 |
| Varint 数值域 | Java writer 接收带类型的 Java 值,按字段类型写 varint。 | protobuf 解码器可接受最长 10 字节的 70 位无符号 varint,再向下强转为int32/uint32/int64/uint64/sint32/sint64/bool,未用高位可承载备选编码。 | canonical writer API 必须按 schema 字段类型带类型签名,从不输出通用uint70值。值必须在编码前适配选定类型;未来的 canonical reader 必须拒绝高位未用位与过宽 varint,除 protobuf 要求的负int32符号扩展外。 | 若增加 canonical reader,补充最大规范 32/64 位 varint、过宽编码、负int32/int64边界的底层测试。 |
| 字段顺序 | Java 生成代码按生成字段号顺序输出已知字段。 | protobuf 解析器接受任意字段顺序;序列化顺序跨实现显式非规范。 | 除 repeated/map schema 另有规定外,按 schema 升序输出。 | 顺序保存在CanonicalSchema;构造器拒绝非升序的单数字段声明。 |
| 未知字段 | Java 生成消息解析任意输入时可保留并重新输出未知字段;Bisq 构造的模型对象在 canonical 前像中不需要未知字段。 | 未知字段可能依实现与版本序列化在已知字段之前或之后。 | canonical 序列化从不输出未知字段。 | 不直接从解析后的 protobuf 消息序列化,只从 Bisq 模型对象序列化。 |
| 重复的单数标量字段 | Java builder 存一个值;解析重复单数字段时 merge 规则保留最后一个标量值。 | wire 上可出现同一单数字段多次且解析为同一逻辑值。 | 每个单数标量字段最多输出一次。 | 增加 schema 测试:除非字段类型显式为 repeated/map,每个字段号只出现一次。 |
| 重复的单数消息字段 | Java 解析合并重复消息字段;序列化构造对象时输出合并后的消息一次。 | 同一消息字段的多个 wire 实例可解析为一个逻辑消息。 | 每个单数消息字段最多输出一次,附带嵌套 canonical 字节。 | 若未来增加 reader/verifier,将重复消息字段视为非规范。 |
int32 | 正值写最短 varint;负值符号扩展占 10 字节。 | 手工字节可用非最小 varint 编码正值。sint32用 zigzag,但int32不用。 | 正值:最短 varint。负值:标准 10 字节补码 varint。默认0省略,除非 schema 另有规定。 | 若引入sint32,为int32与sint32保留独立 writer 方法。 |
int64 | 非负值写最短 varint;负值 10 字节。 | 手工字节可用非最小 varint。 | 非负最短 varint;负值标准 10 字节补码 varint。默认0省略。 | 底层测试覆盖0、1、127、128、最大正值与-1。 |
uint32/uint64 | Java 写最短无符号 varint。 | 非最小 varint 可解析为同一无符号值。 | uint32用于 DAO 提案版本字段,最短无符号 varint,默认0省略。uint64在 Bisq canonical schema 需要前保持不支持。 | 序列化使用 64 位无符号语义的字段前,先增加uint64writer 方法。 |
sint32/sint64 | 当前不支持。Java 会 zigzag 后再 varint。 | 混淆int*与sint*会在负值上改变字节。 | 仅在显式 schema 类型与测试齐备时加入;zigzag 后用最短 varint。 | 需要前一律禁止。 |
bool | false按默认省略;存在时true写 varint1。 | 解析器通常把任何非零 varint 当 true。 | 只写0或1;单数 implicit-presence 字段省略false,除非显式需要 presence。 | 未来 canonical reader 应拒绝1以外的非零值。 |
| Enum | Java 写数值枚举值。Java 声明顺序仅对部分枚举与 protobuf 顺序一致。 | proto3 可保留未知枚举数值;不同语言对未知枚举值的暴露方式不同。 | 使用 schema 定义的枚举 code。仅在该枚举已文档化并测试时允许 ordinal 回退。code0省略,除非 schema 另有规定。 | 每个枚举增加一个测试,锁定范围内全部取值。 |
| 定宽整数 | 当前不支持。Java fixed32/fixed64 用小端定宽字节。 | 无 varint 可变性,但存在有符号/无符号解释与字节序错误风险。 | 需要前禁止;若加入,schema 必须显式命名 fixed32/fixed64/sfixed32/sfixed64。 | 增加字节序的字节级 fixture。 |
| Float / double | Java 把double写为 fixed64 小端字节,只省略原始位正零。 | NaN 有多种位模式;0.0与-0.0语义上容易出意外。 | double仅支持为Double.doubleToRawLongBits的精确输出;原始位正零省略,-0.0输出。float保持不支持。 | 新协议字段优先使用整数定点表示。 |
| String | Java 把String序列化为 UTF-8 字节;生成的 proto3 string 字段要求合法 UTF-8。 | 不同来源在到达 protobuf 前可能对 Unicode 文本做不同规范化。手工 length-delimited 字节中非 string 字段可能存在非法 UTF-8。 | 精确序列化 JavaString的 UTF-8 字节;不做 Unicode 规范化;空单数 string 省略,除非 schema 另有规定。 | 若用户可控 Unicode 影响共识,文档化"刻意不做规范化"。 |
| Bytes | Java 写精确的字节数组/ByteString字节。 | bytes 不透明;等价的高层编码是应用层的。 | 按存储值精确写出字节。空单数 bytes 省略,除非 schema 另有规定。 | 不要把 byte 字段重新解释为嵌套消息,除非 schema 定义了 message 字段。 |
| Length-delimited 消息 | Java 写嵌套消息大小的 varint,再写嵌套字节。 | 非最小长度 varint 可解析为同一长度;重复消息字段可能 merge。 | 长度前缀为最短形式 varint。嵌套字节必须是嵌套 schema 的 canonical 序列化。 | 仅当 schema 确实可能输出零长嵌套消息时,增加零长嵌套消息测试。 |
| 非 packed repeated 字段 | Java 按列表顺序输出每个元素。 | repeated 元素可与其它字段交错且多数情况仍解析为同一列表顺序。 | repeated 字段在其 schema 位置输出,保留列表顺序,不与其他字段交错。 | schema 应标记字段为 repeated 并显式定义元素顺序。 |
| Repeated string/bytes | Java 把每个元素写为 length-delimited 字段,repeated 列表中存在的空元素也输出。 | 空 repeated 元素与"缺失"不同,但自定义 writer 容易误删。 | 按列表顺序保留每个元素,包括零长元素。 | 保留含空 repeated string 元素的 parity 测试。 |
| Packed repeated 数字字段 | proto3 数字 repeated 字段在生成代码中通常默认 packed。 | 解析器通常同时接受 packed 与 unpacked,多个 packed 段可拼接。ADR-027 要求 repeated 标量数字字段使用 packed。 | packedRepeatedInt32在 schema 字段位置输出一个packed 段并保留所有元素(含零值)。canonical writer 不输出 unpacked 编码或多个 packed 段。其他 packed 数字元素类型需要前不支持。 | 任何新的 packed 数字字段类型进入 canonical 前像前,先增加元素级 writer 测试。 |
| Map 字段 | Java map 序列化在未启用确定性序列化时顺序是实现相关的;即使启用,确定性 map 顺序也不保证跨所有语言、所有 key 类型 canonical。部分 legacy Bisq 路径还有显式HashMap顺序热点。 | map 是 repeated entry message:wire 顺序可变、重复 key 后值覆盖、确定性顺序可能因语言/key 类型而异。 | 支持的 map 字段编码为 key 字段1、value 字段2的 repeated entry;每个 schema 必须传入显式 entry 迭代器,如 legacyCollectors.toMap(...)路径用LegacyCollectorsToMapIterator,排序顺序用TreeMapIterator。重复映射键非规范。 | 不依赖 protobuf 确定性 map 序列化;在 schema 中使用前实现并测试所选每种顺序模式。 |
| Oneof | Java builder 存一个选中 case,生成序列化输出那一个字段。 | wire 上可含同一 oneof 的多个字段;解析器通常保留最后看到的。 | schema 必须恰好输出 Java 对象类型选中的那个 oneof 字段,或在允许时一个都不输出。多个 oneof 字段非规范。 | 为支持 schema 图之外的 oneof 分支增加显式不支持路径测试。 |
| 默认标量值 | proto3 生成的 Java 省略 implicit-presence 的默认标量。 | 显式optional字段与 wrapper 消息可为默认值保留 presence。 | implicit-presence proto3 字段省略默认标量值。需要 presence 时 schema 必须显式标记。 | 不要从 Java 默认值推断 presence。 |
| 空单数 string/bytes | Java 生成的 proto3 序列化省略空单数 string 与 bytes。 | 零长 length-delimited 字段通常可解析为同一默认值。 | 省略空单数 string/bytes 字段。需要表示空元素时使用 repeated 字段。 | — |
| Groups | 已废弃,不支持。 | protobuf wire format 支持 start/end group。 | canonical 前像 schema 中禁止 group。 | 若未来请求 group 支持,schema 构造应直接失败。 |
| Extensions / MessageSet / Any | 不支持。 | 这些结构可携带任意嵌套类型 URL、扩展字段或未知数据。 | 禁止,除非未来 schema 定义受限的 canonical 形式。 | 优先使用显式 message 字段。 |
八、Schema 应定义的要素
文档"Schema Implications"一节要求每个CanonicalSchema定义:
- schema 版本(version);
- 字段号(field number);
- 字段类型(field type);
- 字段取值 accessor;
- 省略规则(omission rule);
- 嵌套 compose 或 extend schema(如有);
- repeated/map 顺序规则;
- oneof 选择规则(如有)。
对照源码,CanonicalSchema.Builder 的每个字段方法都把这八要素编码进了Field:number+FieldType+Rule+ getter + 可选嵌套schema+ 可选mapEncoding,version作为 schema 级元数据(CURRENT_VERSION = 1)。
九、真实用例:DAO 状态哈希链
设计文档指出第一个共识敏感用例是DAO hash chain 监控:节点用带版本 canonical schema 编码同一 DAO 状态转换或哈希链输入并比对哈希。
DaoState 双 Schema
DaoState.java 定义了两个 schema:
public static final CanonicalSchema<DaoState> SCHEMA = createSchema(false); public static final CanonicalSchema<DaoState> STATE_HASH_CHAIN_SCHEMA = createSchema(true);createSchema(boolean includeLastBlockOnly)的字段布局:
| 字段号 | 类型 | 内容 |
|---|---|---|
| 1 | int32 | getChainHeight |
| 2 | repeatedCompose | 区块列表;哈希链 schema 只取最后一个区块(哈希链中前哈希已包含历史状态,用全量区块列表会显著变慢——源码注释称 7000 个区块约 1.4 MB、哈希耗时 30 秒,只取最后一个区块降到 7 秒) |
| 3 | repeatedCompose | getCycles→Cycle.SCHEMA |
| 4 | mapStringToCompose | getUnspentTxOutputMap→TxOutput.SCHEMA,LegacyCollectorsToMapIterator |
| 5 | mapStringToCompose | getIssuanceMap→Issuance.SCHEMA,LegacyCollectorsToMapIterator |
| 6 | repeatedString | getConfiscatedLockupTxList |
| 7 | mapStringToCompose | getSpentInfoMap→SpentInfo.SCHEMA,LegacyCollectorsToMapIterator |
| 8 | repeatedCompose | getParamChangeList→ParamChange.SCHEMA |
| 9 | repeatedCompose | getEvaluatedProposalList→EvaluatedProposal.SCHEMA |
| 10 | repeatedCompose | getDecryptedBallotsWithMeritsList→DecryptedBallotsWithMerits.SCHEMA |
源码中的注释解释了为何三个 map 字段刻意使用LegacyCollectorsToMapIterator:
"getUnspentTxOutputMap, getIssuanceMap, and getSpentInfoMap intentionally pass LegacyCollectorsToMapIterator to preserve JAVA_11_HASH_MAP_ORDER for hash-chain canonicalization. Normal protobuf persistence still writes TreeMap order, and includeLastBlockOnly only changes block selection."
这正是"同一对象图、不同 schema 选不同 map 顺序规则"的典型体现:持久化路径(普通 protobuf 输出)与哈希链路径(canonical)可以共享同一模型对象,但顺序规则由各自 schema 决定。
与 legacy 路径的字节级验证
DaoState.getSerializedStateForHashChain 现在调用encodeCanonicalForStateHashChain(CanonicalEncoder.DEFAULT),同时保留一个@Deprecated的getSerializedStateForHashChainLegacy()(基于 protobuf builder)用于验证旧实现与新 canonical 版本产生相同哈希。测试 DaoStateCanonicalEncoderTest.java 进一步把 legacy 顺序"钉死":
JAVA_11_HASH_MAP_ORDER/JAVA_11_TX_OUTPUT_KEY_HASH_MAP_ORDER:固定 40 个 key 在 Java 11HashMap下的迭代顺序,断言LegacyCollectorsToMapIterator复现同一顺序;JAVA_11_TREEIFIED_COLLISION_HASH_MAP_ORDER:用 16 个相同哈希的碰撞 key 验证树化桶内顺序也被复现;- 还有 50,000 个随机化 tx output key 的摘要
33ef83bb...作为回归指纹。
配套的 compare-hashmap-order-jdks.sh 用于跨 JDK 对比顺序差异,佐证"顺序必须冻结在代码内而非依赖运行环境"这一设计决策。
其他已迁移的共识路径
同目录设计文档 protobuf-hash-use-cases.md 是本次迁移的用例台账,可作为本文主题的延伸阅读。与本文直接相关的要点:
- P2P 保护存储键:P2PDataStorage.get32ByteHash 对实现
Canonical的 payload 执行Hash.getSha256Hash(canonical.encodeCanonical());序列号签名则哈希DataAndSeqNrPair的 canonical 字节——该包装器把 payload 字段委托给ProtectedStoragePayload.encodeCanonical(),使每个存储 payload 自己拥有 canonical 哈希前像; - 开发者 Filter 签名:
FilterManager对filter.encodeCanonical()的 SHA-256 签名。Filter.SCHEMA 是一个很好的完整样例:
public static final CanonicalSchema<Filter> SCHEMA = CanonicalSchema.oneof("StoragePayload", 4, CanonicalSchema.<Filter>newBuilder() .repeatedString(1, Filter::getNodeAddressesBannedFromTrading) .repeatedString(2, Filter::getBannedOfferIds) .repeatedCompose(3, Filter::getBannedPaymentAccounts, PaymentAccountFilter.SCHEMA) ... .mapStringToString(6, Filter::getExtraDataMapForCanonical, TreeMapIterator.naturalOrder()) ... .doubleField(30, Filter::getPowDifficulty) ... .packedRepeatedInt32(35, Filter::getEnabledPowVersions) ... .bool(40, Filter::isDisableBsqSwap));它演示了文档中的多个规则:CanonicalSchema.oneof(...)构造 oneof 包装(StoragePayload的字段 4);map 字段6显式声明TreeMapIterator.naturalOrder();double字段(pow difficulty)与 packed repeatedint32(启用的 PoW 版本列表)正是"为迁移 Filter 而新增 canonical 支持"的两类字段;字段 9 的缺号是合法的——省略规则只约束"输出什么",不要求字段号连续。
- 迁移纪律:台账文档明确"canonical 模型在哈希/签名路径使用
encodeCanonical();Proto.serializeForHash()目前委托给 protobufserialize(),仅作为弃用的 legacy 回退保留,当前台账中没有已知仍停留在 legacy 回退上的生产哈希前像",并声明该部分是未来非Canonicalpayload 进入签名/存储键/契约哈希/共识哈希前的评审点。这也呼应了 Proto.java 中@deprecated Use Canonical#encodeCanonical()的注释。 - 兼容性约束(台账"Compatibility Notes"):切换哈希前像会改变 P2P 键、签名验证与共识标识。一条路径只有两种情况可以一步迁移:canonical schema 对已接受的对象形状与当前 protobuf 输出逐字节兼容,或协议有显式激活与 legacy 验证计划。这正是 DAO 哈希链选择
LegacyCollectorsToMapIterator、而新路径(如 Filter extra-data map)选择TreeMapIterator的原因。
十、测试与字节级证据
canonical 编码的正确性完全依赖字节级测试。仓库中与本设计直接相关的测试:
- DaoStateCanonicalEncoderTest.java:legacy HashMap 顺序复现、碰撞桶顺序、大规模 key 摘要;
- GovernanceCanonicalEncoderTest.java 与 BlockchainCanonicalEncoderTest.java:DAO 治理与链模型各 schema 的 parity 测试;
- FilterSerializationTests.java:Filter canonical 字节与 protobuf 输出的 parity(文档明确要求"protobuf parity tests");
- OfferPayloadCanonicalEncoderTest.java、PaymentAccountPayloadCanonicalEncoderTest.java、AccountingCanonicalEncoderTest.java、AlertCanonicalEncoderTest.java、DisputeAgentCanonicalEncoderTest.java、RawBlockCanonicalEncoderTest.java 等:各 payload 家族的字节等价验证。
这些测试与文档中"每个支持的特性都需要显式 schema 规则与字节级测试"的要求一一对应:新增字段类型(如double、packedRepeatedInt32、map)进入 canonical 前像前,必须先有对应测试。
十一、设计要点小结
- schema 即协议:字段号、类型、省略规则、嵌套 schema、map 顺序全部显式声明在 Java 代码中,构建期强校验(升序字段号、protobuf 字段号合法区间、map 必须带迭代器),消除了"文档/代码/实现三份漂移";
- 可变性逐条消灭:tag/长度/数值全部最短 varint;单数字段至多一次;无 unknown field;默认值、空串、空字节、
false、枚举0、正零 double 按 proto3 implicit-presence 省略;packed 仅一个段;map 键重复即异常; - 兼容性是第一约束:canonical 子集在需要处与
protobuf-java输出逐字节一致,并保留 legacy 验证路径与 parity 测试;切换哈希前像必须字节兼容或有显式激活计划; - Bisq 特有的工程妥协:
LegacyCollectorsToMapIterator把 Java 11HashMap的迭代顺序(含桶树化细节)完整复刻为纯代码算法,使 DAO 哈希链在所有 JDK 上确定;这是"兼容模式必须按 schema 文档化"而非通用规则的典型; - 明确的边界:无解码器、无
uint64/sint*/float/定宽整数/Extensions/Groups 支持,任何新特性都遵循"schema 需要 + 类型显式 + 字节级测试"三件套才准入。
对贡献者而言,为 Bisq 模型新增 canonical 支持的标准动作是:编写CanonicalSchema(遵循字段号升序与显式 map 迭代器)→ 实现Canonical#encodeCanonical→ 编写与 protobuf 输出的 parity 测试 → 将该路径登记到 protobuf-hash-use-cases.md 台账。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考