架构
白皮书的工程篇伴读 — 参考节点如何实现这套设计,以及其背后的各项决定。是解释性的,不是规范性的。
协议是参数文件与黄金 向量,外加各项具名规则,无论它们在何处被定义; 线路规范承载着对等层的各项要求。本 页解释那个接口面,并记录其背后的各项决定。凡是它与规范性接口不一致之处, 以规范性接口为准,而本文会被更正。 完整的工程篇伴读是 docs/ARCHITECTURE.md。
两个谓词,两个引擎#
一份凭证从头到尾的一生:
wallet or sequencer network every full node
+-------------+ gossip +--------------+ +---------------------+
| build cert | ---------> | cert topic | ------------> | STATELESS PIPELINE |
| (reads, | | (TLS gossip) | | V-rules, strict |
| writes, | +--------------+ | Ed25519, program |
| sigs, fee, | | re-execution |
| deposit) | | -> mark VALID |
+-------------+ +----------+----------+
| mempool
v
miner (any node) +---------------------+
+----------------------+ block | FOLD (sequential) |
| order the hash list, | ---------> | per certificate: |
| append forced certs, | gossip | APPLIED / SKIPPED / |
| RandomX solve | | FORCED_FAILED / |
+----------------------+ | DROPPED -> state |
+---------------------+
有效性是无状态且并行的:它在每个节点上对每份凭证运行一次,先于区块且独立于区块。可应用性是有状态且串行的:它在折叠内部、在凭证被提交的位置上运行。矿工不执行交给它的任何东西:它对已经看到通过验证的哈希排序并求解工作量证明,或者在 Era 2 切换到权益证明之后,为它被抽中的时隙签名。它唯一的一项执行属于强制路径,自 Era 1 起:它可以针对其区块末尾的状态执行开放意图并追加结果,每个区块至多 16 份,随后每个节点都像核查任何其他凭证一样核查它们。折叠是在一个内存工作集上的紧凑循环 — 比较、相加、写入 — 并且根本不执行任何东西。
工程原则#
| 原则 | |
|---|---|
| P1 | 折叠是神圣的。状态转换函数住在一个纯粹的包里,没有 I/O,没有时钟,没有 goroutine,没有 map 遍历,也没有浮点数。它是唯一一处其缺陷在事后无法修补的代码。节点里的其他一切都是可替换的管道。 |
| P2 | 确定性优先于性能。任何有不确定性风险的优化,在共识代码中一律被否决。性能属于无状态流水线,在那里它是安全的。 |
| P3 | 没有管理员密钥,没有特权 RPC。不存在任何一条代码路径,能让某把密钥去暂停、升级、铸币或重组。如果它不在折叠规则里,它就不存在。 |
| P4 | 共识面要小。core/ 不引入标准库以外的任何东西。节点的其余部分可以使用生态库;核心不可以。 |
| P5 | 规范优先。黄金向量就是协议。这份 Go 代码是一个参考实现;一个能通过这些向量的独立实现是对等实现,不是分叉。正是这一点使得维护者最终可以是无名之辈。 |
| P6 | 从 v0.1 起即可复现。钉死的 Go 工具链、-trimpath、钉死的依赖。信任从二进制文件转移到代码上,而这是一个匿名作者所能提供的唯一一种信任。 |
| P7 | 一个二进制文件,由高度把关。机器、定序器机制和强制路径都编译进每一个发行版,并在其启用高度 h1_vm(mainnet 上为 80,640)之下被拒绝,而 Validate 要求该高度落在一个纪元边界上。Era 1 之所以到来,是因为链走到了某个数字,绝不是因为有人请求运营者升级。之后的各个时代不在这个二进制文件里:每一个都作为独立的发行版、以硬分叉的方式发布。 |
P3 是一个持怀疑态度的读者最该用力追问的一条,而国库正是该追问的地方:创世中根本没有任何密钥,也没有任何动用途径,因此不存在可以持有、委托或窃取的特权。国库在高度 259,200 之前无法被花费,而无论什么规则开启它,都会作为一次共识变更到来 — 与其他任何变更相同的社会机制,受制于同样的拒绝,并且即便那时也只能移动恰好一个存储单元。创始人份额是一笔入账,而不是一把密钥:每笔补贴的 3% 付给参数文件中固定的一个地址,直到高度 3,153,600,Era 2 把它读作纪元 1,095。该地址持有币,此外触及不到任何东西。管理员密钥则是一把在任何人同意之前就已存在、并且能触及一切的密钥。
密码学原语#
| 角色 | 选择 | 为什么 |
|---|---|---|
| 签名 | Ed25519 | 支持批量验证(通往 GPU 的入口匝道),没有可锻性,密钥极小。在创世时就钉死了严格规则:公钥和 R 必须采用规范编码,公钥必须无挠且不属于低阶,验证必须是无余因子的。 |
| 哈希 | BLAKE3 | 凭证 id 与区块 id、地址、状态根。快到足以按转发线速做哈希;对纪元状态根而言又对并行友好。 |
| 工作量证明 | RandomX | 为 CPU 优化。它是整棵代码树里唯一的 cgo,藏在一个构建标签之后,不带该标签的构建里根本没有它。pow_engine 位于共识根之内,所以一个持有错误引擎的二进制文件会拒绝启动,而不是去接受错误的证明。 |
| 域分离 | 强制 | 每一次哈希都是 blake3(tag ‖ payload)。签名同时覆盖链 id 和共识根,这就同时消灭了跨网络的重放和跨同一网络两次化身的重放。 |
正是拒绝挠元素让批量路径变得安全。一把混合阶的密钥并不是 低阶的,所以没有哪份封禁名单能拦住它,而它恰恰是带余因子的批量验证器与无余因子的单次验证器 发生分歧的地方。当密钥和 R 都落在素数阶子群里时,两者 可被证明是等价的 — 因此批量验证器可以带余因子,前提是它在批处理之前 施加同样的编码规则和挠规则。那份义务是这项选择的代价,而批量验证器还不存在。
规范编码与各类标识符#
所有共识对象都是 SSZ 容器:单一的规范字节编码,没有 map 顺序问题,没有可选字段的歧义,有便于低成本部分解析的固定偏移量,以及原生的 merkle 化。
cert_id = blake3("zcd/certid/v1" || ssz(certificate with an empty signature list))
cert_exemplar = blake3("zcd/cert/v1" || ssz(certificate))
block_id = blake3("zcd/block/v1" || ssz(header))
前两者是回答不同问题的不同摘要,而一个在该用其中之一的地方用了另一个的 实现,在金钱意义上是坏的。id 回答的是 这份授权是否已被计费;实例字节的哈希回答的是这些字节是否证明了 它。两者从不共用一个键。
签名位于 id 的原像之外,因为签名是一次随机化的展示: 签名者挑选 nonce,每一个 nonce 都会对同一个消息体产出另一个同样有效、同样完美规范的签名, 而没有哪个验证者能核查用的是哪一个。倘若它们在里面,一份授权就会 拥有无穷多个 id,每一个都可计费,每一个都能由其所需签名者中任意一位 越过其他人的权限而单独产出。
一个位于 id 之外的出价,会是一个任何位于传输途中的人都能改写的出价 — 被抬高以便通过基础费 烧掉签名者的余额,或者被清零以便让这份凭证进不了任何区块。一份 凭证付出什么,是它的签名者所授权内容的一部分,所以它要被哈希,也要被签名。日后一次 “为了转发灵活性而把费用移出被签名的消息体”的编码变更,看起来会像 一次优化,而它会是一条盗窃通道。
随之而来的规则是:解析,而不要验证两遍。解码本身就强制施行规范 形式,所以一个被解码出来的对象在构造上就是结构合法的,而各规则引擎从不 重新核查形状。
存储单元模型#
存储单元是一个槽位上的值。存储单元的值是以大端序存放在 32 字节中的 无符号 256 位整数。不存在的存储单元读出来是零 — 零就是不存在,而这是一项 共识要求,而不是实现上的图省事:它让状态根成为状态的函数, 而不是产生出该状态的那段历史的函数。
另外,协议还保有一份已花费地址注册表:一个由签名权限 已被烧毁的一次性地址构成的、永久性的共识集合。
Addr = version || blake3("zcd/addr/v1" || version || payload)[:31]
| 版本 | 种类 | 借记授权 |
|---|---|---|
0x01 | 一次性用户地址 | 所有者签名;任何进行借记的凭证还必须携带一个显式的 MARK_SPENT。它一旦应用,该地址之下的每一次读取和写入都将永远失败。 |
0x02 | 持久用户地址 | 所有者签名;可永久重复使用。永远不会进入已花费注册表。 |
0x03 | 资产 | 由该资产不可变的权限存储单元管辖。 |
0x00 | 协议 | 仅折叠可用:纪元信标、基础费存储单元、coinbase 成熟期环、国库存储单元、定序器计数器。 |
0x05 | 合约 | 不由任何密钥控制:没有任何密钥会哈希到这样的地址。由合约的代码和折叠规则决定。 |
0x04 | 未定义 | 其下不存在任何存储单元;指定这种地址的凭证无效。 |
类型化访问是并行性的来源。一次读取声明它如何约束其存储单元 — EXACT,或守卫 GUARD_GE、
GUARD_LE、GUARD_EQ 之一 — 而一次写入声明它做什么:
SET、DELTA_ADD、DELTA_SUB 或 MARK_SPENT。只有
EXACT 参与计算,也只有 EXACT 会在存储单元变动时过时;
守卫不返回任何东西,增量也不读取任何东西,因此重新执行始终是精确读取的纯函数,而对同一个槽位的任意数量的带守卫借记和贷记都可交换。每份凭证在每个槽位上只遵循一种规则:设置和增量从不共用一个槽位。写入零会删除该存储单元,因此从未被写过的槽位和被写成零的槽位是同一个槽位。
一次性与持久之分,是一项以地址位体现出来的钱包策略,而不是两本账。 钱包默认为每一笔入账付款使用一个新的一次性地址,这是零争用的路径;谁 想要一个账户,就用持久的版本。挖矿奖励应当进入持久地址:共识接受两种地址作为收款地址,但一次性地址在被花费的那一刻,会把它持有的每一笔奖励都烧掉, 因此节点拒绝把一次性地址用作收款地址。
注册表条目从不被压缩。已花费地址之下的存储单元值 可以在回滚视界之后被剪除,但那条把该地址记录为已花费的条目,正是 阻止它被复活的东西。它是只追加的共识状态,每个地址约 33 字节, 永远保留 — 这个协议诚实承认的尚未解决的问题,在结构上与每一种 作废符集合设计共有。
无状态有效性,以及计费法则#
V 规则在每一份凭证上运行,是并行的,发生在进入内存池之前以及区块 验证期间,且需要零状态:规范形式与链 id,且每个列表都在其上限之内;每一个签名都要在签名根之上按严格 Ed25519 验证通过,且签名集合既充分又最小;程序针对所声明的读取重新执行,恰好产生所声明的写入;每一个需要授权的写入都有授权,且保证金存储单元无条件地带有签名;保证金是某个用户地址的原生余额,并足以覆盖该凭证的最坏情况;自洽性,即没有任何写入与对同一槽位的读取相矛盾,也没有任何被借记的一次性地址逃过被标记为已花费;不写入任何协议存储单元;所指定的每个地址都有已定义的版本字节;以及,在定序器密钥非零的合约中,该密钥在精确路径上的签名。
这个系统的计费法则只有一句话,而它就是要牢牢抓住的那件事:
一个签名,至多一次计费,且绝不在其签名者无从回避的位置上计费。
折叠把一个区块分为三类来排序,不给生产者留任何自由裁量:首先是
fill,按它们所属的定序器流水线的顺序;然后是其他所有凭证,按
(deposit address, Seq, id);最后是强制
凭证,按 id。随后每份凭证恰好有四种结果之一,黄金向量为它们命名:
| 结果 | 何时 | 谁付费 |
|---|---|---|
APPLIED | 每一个所声明的读取都成立。 | 保证金提供者,按实际成本:基础费销毁,优先费归生产者。 |
SKIPPED_STALE、SKIPPED_OVERFLOW | 某个读取不再成立,或某个增量会使值离开 [0, 2²⁵⁶)。不写入任何东西。 | 保证金提供者:一笔固定的跳过费,销毁。对于 fill,是定序器 — 绝不是用户。 |
FORCED_FAILED | 一份调用回滚或突破了其界限的强制凭证。 | 它的付款地址;除此之外不写入任何东西。 |
DROPPED | 保证金无法被预留:该存储单元没有资金或已被花费。 | 无人付费。不标记为已见,因此可以针对一笔有资金的保证金重新提交。 |
被应用、被跳过和强制失败的凭证都会被标记为已见,而 打包一份已见、已过期或出价不足的凭证会使区块无效,因此任何生产者都无法 重新打包或延迟别人的凭证来收割其保证金。那些无人可收费的失败 — 一个流水线计数器顺序错乱或 fence 计数器已经变动的 fill,一份读取不成立的强制凭证 — 则会使区块无效:生产者是根据它所持有的状态构建该区块的,它正是本可以知道这一点的一方。
时代关口#
启用高度存放在 spec/params.json 中。h1_vm = 80,640 开启
Era 1:cEVM 和预部署库,带有定序器密钥、管理员和 fence 计数器的合约记录,意图与 fill,定序器目录,以及强制路径。该时代自身的常量
— 8 个区块的意图窗口,每个区块 16 份强制凭证、每份 4,000 个派生单位,定序器目录条目在其通告凭证的 TTL 之后 259,200 个区块的存续期 — 是编译进去的,而不是由参数文件承载,因为增加一个参数会改变每一个在运行网络的创世 id;关口区块会把它们写入协议存储单元,因此从那时起状态根就对它们作出承诺。
h1_bond 在 mainnet 和公共 testnet 上等于 h1_vm,因此质押操作在那里永远不会开启:Era 1 中没有权益质押。代码树中仍然保留着 v1.0 的承保、租约和强制队列规则,它们只在开发网络上有效,位于其 h1_vm 之上、其自身的 v1 关口之下的一个 256 个区块的窗口中;这个窗口之所以存在,是为了让记录那套机制的向量保有一个使其有效的网络。h2_pos = 1,576,800 为权益证明保留,在创世时冻结,没有任何规则读取它。Era 2,即权益证明,以硬分叉方式作为其自己的发行版交付,其常量像 Era 1 的常量一样被编译进去:它按高度在纪元 182 开启质押,当活跃权益在 30 个纪元内持有供应量的 10% 时、最迟在纪元 273 开始其权益爬坡期,并在爬坡期完成时切换到 5 秒的时隙。从切换起,它把每一个实际上是时长的创世常量按其所度量的单位来读取,因此参数文件不会改变。
仓库布局#
zycord/
spec/ parameter sets, golden vectors, library images <- THE PROTOCOL
core/ consensus-critical; standard library only (P4)
types/ crypto/ ssz/ u256/ state/ validity/ fold/ params/ genesis/
cevm/ the certificate-adapted EVM; vendored interpreter, pure Go
stdlib/ the pre-deployed library, its addresses and code hashes
pow/randomx/ the mainnet engine: vendored C++, cgo, behind a build tag
node/ storage/ chain/ verify/ mempool/ miner/ p2p/ sync/ rpc/ stratum/
wallet/ key management, certificate builders (reference; not consensus)
contracts/ the reference contracts, in Solidity
sim/ simulator, fuzz harnesses, differential refold, chaos soak
cmd/ zycordd, zcd
desktop/ the wallet in a native window — a separate Go module
docs/ architecture, protocol, operating guide, whitepaper
依赖箭头只朝内指 — node → core、
wallet → core,绝不反向 — 而 core/ 内部的任何东西
都不会伸到 core/ 和标准库之外。有一个例外,它被点名并被强制执行:
core/pow/randomx 是整棵代码树里唯一的 cgo,而它只在那个构建
标签之下才会编译,所以每一个不带该标签的构建仍然是只依赖标准库的,也不需要 C 工具链。CI
会运行强制执行这一点的检查,因为第三方检查是靠 grep 模块路径来做的,而 cgo 一个模块路径都没有
— 这曾让这条规则在被补上之前无人执行。
它是怎么被测试的#
- 黄金向量。每一条折叠规则、区块规则和有效性规则都有正例和反例,形式为
(pre-state, block) → (post-state | invalid, outcomes, fees)。这套测试集就是面向独立实现的兼容性契约。 - 骚扰攻击测试集。重新打包已应用和已跳过的凭证、过期后打包、出价不足时打包、在提议者洗牌下的依赖链、烧毁与退款的循环、第三方贷记风暴、铸造上限的边界竞争。
- 基于性质的测试。在提议者顺序置换下的折叠确定性、增量的可交换性、ABA 容忍度,以及守恒 — 其中包括在途保证金、成熟环和国库存储单元,因为一项遗漏了国库的守恒检查,会把每一个区块都报告成在凭空创造价值。
- 差分测试。一个刻意写得朴素的第二份折叠实现,为显而易见而非为速度而写,与真正那份对拍模糊测试。出现分歧就是发布的阻断项。
- 对抗性模拟。跳过风暴、保证金抽干竞争、往里塞丢弃项的矿工、重组酷刑、时间戳操纵、轻量日蚀转发场景。场景配置都已提交入库;每次运行都可由种子复现。
- 并发,且是刻意的。每一个被不止一个 goroutine 触及的组件,都有一个本身就是并发的测试,其形态与进程实际使用的形态一致。在一个单 goroutine 的测试集上跑
-race什么也测不到,却会报告成功。 - 混沌浸泡测试。真实的节点进程通过真实的套接字,经由一个注入延迟、抖动、丢包和分区的代理进行通信,同时节点会被随机地以
SIGKILL杀掉。正是这个面找出了整套-race测试集都漏掉的那个数据竞争。
创世是一件产物,不是一场仪式#
zcd genesis 会产出创世区块 — 空状态、已初始化的信标存储单元、
空的已花费注册表、没有任何形式的分配 — 以及它的 id。启动事先
对代码标签、参数哈希、创世 id 和启动时间作出了承诺,而参数集携带了对一条启动消息的承诺,该消息的摘要必须由区块 1 揭示,因此
没有人能在宣布的开始时间之前挖这条链。任何人都可以在任何机器上、从源代码出发,用毫秒级的时间把前四样重建出来。除此之外没有别的东西需要信任。