news 2026/11/1 1:48:30

Bisq 规范化编码(Canonical Encoding)设计:Protobuf 兼容的确定性哈希前像

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Bisq 规范化编码(Canonical Encoding)设计:Protobuf 兼容的确定性哈希前像

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 兼容:兼容性基线而非解析器宽容度

这一节的设计立场在文档中反复强调,值得单独展开:

  1. 既有前像(DAO 状态、提案、盲投等)历史上由protobuf-java生成,任何替换编码都必须与这些既有字节逐字节兼容,否则哈希链断裂、P2P 存储键失效、签名全部失效;
  2. 因此 Bisq 的 canonical 子集"在需要兼容性处与protobuf-java输出一致",并通过字节级 parity 测试锁定;
  3. 凡是 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 withCollectors.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 需要前保持不支持。
MapsADR-027 版本 1 拒绝 map。Bisq 仅在 schema 声明显式 entry 顺序模式时支持 map,并把重复映射键视为非规范。

六、通用规则(General Rules)

文档给出的八条全局规则,逐条在实现中可验证:

  1. schema 是事实来源:字段号、字段名、字段类型、省略规则、嵌套 compose/extend schema、顺序规则都由 schema 决定,不允许临时 writer 代码;
  2. 字段按字段号升序输出,除非 schema 显式定义了 repeated/map 顺序规则;
  3. canonical 前像字节中从不输出未知字段;
  4. 从不输出原始额外字段或尾部字节;
  5. 单数字段最多输出一次;
  6. 所有 tag、长度前缀与整数值使用所选字段类型的最短合法 varint,例外是负数int32/int64使用 protobuf 标准 10 字节补码 varint;
  7. 嵌套消息以 length-delimited 形式输出,内容是其自身 schema产生的 canonical 字节;
  8. 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 行为不够清晰时
字段 tagJava 以(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/uint64Java 写最短无符号 varint。非最小 varint 可解析为同一无符号值。uint32用于 DAO 提案版本字段,最短无符号 varint,默认0省略。uint64在 Bisq canonical schema 需要前保持不支持。序列化使用 64 位无符号语义的字段前,先增加uint64writer 方法。
sint32/sint64当前不支持。Java 会 zigzag 后再 varint。混淆int*与sint*会在负值上改变字节。仅在显式 schema 类型与测试齐备时加入;zigzag 后用最短 varint。需要前一律禁止。
boolfalse按默认省略;存在时true写 varint1。解析器通常把任何非零 varint 当 true。只写0或1;单数 implicit-presence 字段省略false,除非显式需要 presence。未来 canonical reader 应拒绝1以外的非零值。
EnumJava 写数值枚举值。Java 声明顺序仅对部分枚举与 protobuf 顺序一致。proto3 可保留未知枚举数值;不同语言对未知枚举值的暴露方式不同。使用 schema 定义的枚举 code。仅在该枚举已文档化并测试时允许 ordinal 回退。code0省略,除非 schema 另有规定。每个枚举增加一个测试,锁定范围内全部取值。
定宽整数当前不支持。Java fixed32/fixed64 用小端定宽字节。无 varint 可变性,但存在有符号/无符号解释与字节序错误风险。需要前禁止;若加入,schema 必须显式命名 fixed32/fixed64/sfixed32/sfixed64。增加字节序的字节级 fixture。
Float / doubleJava 把double写为 fixed64 小端字节,只省略原始位正零。NaN 有多种位模式;0.0与-0.0语义上容易出意外。double仅支持为Double.doubleToRawLongBits的精确输出;原始位正零省略,-0.0输出。float保持不支持。新协议字段优先使用整数定点表示。
StringJava 把String序列化为 UTF-8 字节;生成的 proto3 string 字段要求合法 UTF-8。不同来源在到达 protobuf 前可能对 Unicode 文本做不同规范化。手工 length-delimited 字节中非 string 字段可能存在非法 UTF-8。精确序列化 JavaString的 UTF-8 字节;不做 Unicode 规范化;空单数 string 省略,除非 schema 另有规定。若用户可控 Unicode 影响共识,文档化"刻意不做规范化"。
BytesJava 写精确的字节数组/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/bytesJava 把每个元素写为 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 中使用前实现并测试所选每种顺序模式。
OneofJava 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/bytesJava 生成的 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)的字段布局:

字段号类型内容
1int32getChainHeight
2repeatedCompose区块列表;哈希链 schema 只取最后一个区块(哈希链中前哈希已包含历史状态,用全量区块列表会显著变慢——源码注释称 7000 个区块约 1.4 MB、哈希耗时 30 秒,只取最后一个区块降到 7 秒)
3repeatedComposegetCycles→Cycle.SCHEMA
4mapStringToComposegetUnspentTxOutputMap→TxOutput.SCHEMA,LegacyCollectorsToMapIterator
5mapStringToComposegetIssuanceMap→Issuance.SCHEMA,LegacyCollectorsToMapIterator
6repeatedStringgetConfiscatedLockupTxList
7mapStringToComposegetSpentInfoMap→SpentInfo.SCHEMA,LegacyCollectorsToMapIterator
8repeatedComposegetParamChangeList→ParamChange.SCHEMA
9repeatedComposegetEvaluatedProposalList→EvaluatedProposal.SCHEMA
10repeatedComposegetDecryptedBallotsWithMeritsList→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 前像前,必须先有对应测试。

十一、设计要点小结

  1. schema 即协议:字段号、类型、省略规则、嵌套 schema、map 顺序全部显式声明在 Java 代码中,构建期强校验(升序字段号、protobuf 字段号合法区间、map 必须带迭代器),消除了"文档/代码/实现三份漂移";
  2. 可变性逐条消灭:tag/长度/数值全部最短 varint;单数字段至多一次;无 unknown field;默认值、空串、空字节、false、枚举0、正零 double 按 proto3 implicit-presence 省略;packed 仅一个段;map 键重复即异常;
  3. 兼容性是第一约束:canonical 子集在需要处与protobuf-java输出逐字节一致,并保留 legacy 验证路径与 parity 测试;切换哈希前像必须字节兼容或有显式激活计划;
  4. Bisq 特有的工程妥协:LegacyCollectorsToMapIterator把 Java 11HashMap的迭代顺序(含桶树化细节)完整复刻为纯代码算法,使 DAO 哈希链在所有 JDK 上确定;这是"兼容模式必须按 schema 文档化"而非通用规则的典型;
  5. 明确的边界:无解码器、无uint64/sint*/float/定宽整数/Extensions/Groups 支持,任何新特性都遵循"schema 需要 + 类型显式 + 字节级测试"三件套才准入。

对贡献者而言,为 Bisq 模型新增 canonical 支持的标准动作是:编写CanonicalSchema(遵循字段号升序与显式 map 迭代器)→ 实现Canonical#encodeCanonical→ 编写与 protobuf 输出的 parity 测试 → 将该路径登记到 protobuf-hash-use-cases.md 台账。

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/1 1:47:26

DXVK 3步上手:让 Direct3D 游戏在 Linux 上跑 Vulkan 的完整方法

DXVK 3步上手&#xff1a;让 Direct3D 游戏在 Linux 上跑 Vulkan 的完整方法 【免费下载链接】dxvk Vulkan-based implementation of D3D8, 9, 10 and 11 for Linux / Wine 项目地址: https://gitcode.com/gh_mirrors/dx/dxvk DXVK 是一个基于 Vulkan 的 Direct3D 8/9/1…

作者头像 李华
网站建设 2026/11/1 1:46:50

Python ai-engine-sdk 包详解与实战案例

1. 引言ai-engine-sdk 是一个面向 Python 开发者的 AI 引擎集成工具包&#xff0c;旨在简化大语言模型、向量检索、知识库问答等能力的接入流程。它通过统一的接口封装了底层 API 调用、参数校验、结果解析和错误处理&#xff0c;让开发者可以更专注于业务逻辑本身。本文将从功…

作者头像 李华
网站建设 2026/11/1 1:45:44

测试04234测试04234

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/1 1:45:24

agent框架langgraph4j原理源码分析 图 I状态图,推理框架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华