作者:jamesob

来源:https://github.com/jamesob/assumeutxo-docs/tree/2019-04-proposal/proposal

本文档撰写于 2019 年 4 月。

Bitcoin Core #27596 完成了 assumeutxo 项目的第一阶段,包括了同时使用一个假定有效(assumedvalid)的链状态快照和在后台进行完整验证同步所必需的所有余下变更。它通过 RPC(loadtxoutset)使 UTXO 快照可加载,并在链参数(chainparams)中添加了 assumeutxo 参数。

尽管该功能集在激活前在主链上都不可用,但这个合并标志着到达了多年努力的顶点。该项目在 2018 年提出在 2019 年正式确定,将显著改善首次接入网络的新的全节点的用户体验。后续合并包括 Bitcoin Core #28590#28562#28589

—— Optech Newsletter #272

摘要

本文为 assumeutxo 提议了一种实现和部署计划。AssumeUTXO 使用序列化的 UTXO 集,以持续地减少冷启动一个可用的比特币节点所需的时间,同时保证安全性上的变更是可以接受的。

设计目标

  1. 为非业余的用户运行全验证节点提供一条现实的大道,
  2. 避免显著增加存储负担,并且
  3. 不在安全性上作出重大让步。

计划

这个特性可以分成两步(或者三步)来部署:

  1. 可以创建 UTXO 快照(约 3.2 GB)并通过 RPC 来加载,以取代常规的初始化区块下载(IBD)流程。
    • UTXO 快照将通过 bitcoind 以外的方法来获得。(译者注:“bitcoind” 即 “Bitcoin Core” 程序的后台进程。)
    • 一个硬编码的 assumeutxo 哈希值将确定哪个快照被认为是有效的。
    • 快照的异步验证将在加载好之后,在后台运行。这个阶段包含了绝大部分(甚至是全部)需要对现有的验证代码作的变更。(见这个 PR
  2. 快照可以通过 bitcoid 来生成、存储和传输。
    • 为了缓解关于 DoS 和存储消耗上的顾虑,节点将存储跨越三个快照(一个当前快照,两个历史快照)的 FEC(前向纠错编码)分割的数据块的子集,预计存储负担约为 1.2 GB。
    • 冷启动的节点(在启用 assumeutxo 的时候)会从多个对等节点处获得这些数据块,重新组装成一个快照,然后加载。
    • 硬编码的 assumeutxo 数值将从一个内容哈希值转变成一个默克尔根值,该根值承诺一个特定快照的数据块集合。
    • 我们可能会考虑为本地节点存储添加一个滚动的 UTXO 集哈希值,从而更快地访问预期的 UTXO 集的哈希值,而且可能可能会使用这个数值来形成 assumeutxo 承诺。
  3. (在遥远的未来)可以考虑在共识中添加给定高度的 UTXO 集哈希值的承诺。这个快照以及后台的验证程序可能会被复用,在我们迁移到对 UTXO 集的更加紧凑的表示形式(比如 utreexoUHS 或者累加器)的时候。

如果有什么部分难以理解,请继续阅读以了解更多的细节。

现在,你可以帮助审核这个提议以及代码草稿。

资源

已经熟悉这些想法了?

如果你一直在跟踪相关的讨论、已经理解了 assumeutxo 的基本原理,你可以直接跳到下文的 “安全性” 章节。

致谢

我希望对帮助这个提议的人们表示感谢,虽然他们不应该为我在这份文档以及相关的代码中可能犯的任何愚蠢的错误负责。他们是:

Suhas Daftuar、Pieter Wuille、Greg Maxwell、Matt Corallo、Alex Morcos、Dave Harding、AJ Towns、Sjors Provoost、Marco Falke、Russ Yanofsky,以及 Jim Posen。

基本原理

什么是 UTXO 快照?

“UTXO 快照” 是在区块链的某个特定高度上的 “未花费的交易输出(UTXO)” 集合的一个序列化版本。这些序列化的 UTXO 会被打包并带上一些元数据,例如:

  • 本快照所包含的 UTXO 的总数量
  • 本快照所浓缩的最新区块的区块头(其 “基础”),

等等。你可以在 assumeutxo pull request 中看到完整的数据结构(形式可能会变化)。

什么是 assumeutxo

它是嵌入软代码中的一片数据,承诺了一个序列化的 UTXO 集的哈希值,这个 UTXO 集被认为是在特定区块高度下形成的真实集合。这个承诺的最终形式还有待讨论,因为生成它要耗费大量的计算,而且它的结构也会影响我们如何存储序列化 UTXO 集合以及在对等节点间传输它。但当前来看,它就是使用现有的 GetUTXOStats() 功能所生成的 UTXO 集内容的一个基于 SHA-256 的哈希值。

那么,它有什么用呢?

我们可以使用 UTXO 快照以及 assumeutxo 承诺,从而极大地减少冷启动一个可用的比特币节点所需的时间,并且其安全模式是可以接受的。

当前,初始化区块下载程序所需花费的时间会随着区块链历史的增加而线性增加。不管在哪里,一个新安装的 bitcoind 总要花费至少 4 个小时甚至几天来走完流程,具体时间取决于你的硬件和网络带宽。这个过程会劝退想要运行全节点的用户,促使他们转向安全模式更差的客户端。

快照加载是怎么实现的?

在加载一个快照的时候,这个快照会解序列化、变成一个完整的链状态(chainsate)数据结构,该数据结构包含了对区块链(chainActive)的一种表示以及 UTXO 集(既存放在硬盘中,也缓存在内存中)。加载快照所得的 chainstate 会跟加载快照之前的原 chainstate 同时存在。在接受一个被加载的快照之前,必须先从对等节点网络中检索得到一条区块头链,并且该区块头链应该包含该快照所压缩的最新区块(其 “基础”)的区块哈希值。

加载好快照之后,快照 chainstate 会执行初始化区块下载,从快照状态开始,一直下载的网络的链顶端。然后,系统将允许操作,就像 IBD 已经完成了一样,并且这个假定有效的快照 chainstate 会被视为 chainActive/pcoinsTip/等等。

在快照 chainstate 追上网络的链顶端之后,原来的 chainstate 会在后台恢复因为加载快照而中断的区块初始化下载。这个 “后台验证” 流程跟激活的(快照)chainstate 是异步的,所以系统可以如常服务(比如,运行钱包操作)。这个后台验证的目的是检索所有的区块文件并完全验证整条链,直到快照的起点(即其 “基础” 高度)。

在后台验证完成之前,我们会拒绝加载任何 bestblock 标记低于快照基础高度的钱包,因为我们没有执行重新扫描所需的区块数据。

一旦后台验证完成,我们就可以丢弃原先的 chainstate,因为快照 chainstate 已经被证明是完全有效的了。如果因为某些原因,后台验证产生了一个 UTXO 集哈希值,跟快照所声称的不同,我们会给出明确的警告并使程序停止运行。

安全性

引入 assumeutxo 是否改变了安全模型?

如果我们讨论的是用户是否需要在辨别什么是 有效的/无效的 比特币状态时信任某一些开发者(及其程度),那么:不,使用 assumeutxo 并不会在实质上改变现在比特币 “信任开发者” 的程度。

现在,比特币软件也带有硬编码的 assumevalid 数值。这个数值定义了,如果你在区块头链上看到了某个区块,那么该区块以前的区块的签名检查就可以全都跳过,作为一种性能优化措施。

使用 “assumevalid” 模式,你假设平等审核你所用软件的人可以运行一个全节点、验证所有区块直至(包括)某一个区块。并不需要进一步信任(假设)平等审核你所用软件的人懂得这个软件的编程语言、懂得共识规则;实质上,可以说需要的信任更少了,因为 没有人 完全懂得 C++ 这样的复杂语言,而且 没有人 可能懂得公式规则的每一种可能的细微差异 —— 然而,几乎每个懂技术的人都可以使用 -noassumevalid 启动一个节点,并等待几个小时,然后检查 bitcoin-cli getblock $assume_valid_hash 所返回的 "confirmations(确认次数)" 字段不为 -1

David Harding

Assumeutxo 的想法也是类似的,而且会以一种更严格的方式来指定(不能通过 CLI 来指定)。硬编码的 assumeutxo 数值将以跟 assumevalid 数值完全相同的方式得到提议和审核(通过 pull request),而且会在提议和合并之间留出足够长的时间,从而让任何感兴趣的人可以自己重新生成相同的数值(即验证)。

但是,代码中的这个哈希值假设了某一个链状态的有效性,岂不就像开发者在决定 “正确的链” 是哪一条?

这个数值跟开发者所提议的其它任何代码变更没有区别;实际上,在另一个更加模糊的代码部分中偷藏某种实现了扭曲的有效性的反向逻辑,会容易得多,比如, CCoinsViewDB 可以修改成在某些特殊条件下总是承认一笔钱的存在性,或者网络也可以修改成仅跟特定的对等节点沟通。

assumevalid/assumeutxo 数值的无遮掩特性,实际上让用户的参与更加直接,因为如何审核这种优化是显而易见的。

同样值得指出的是,存在 assumevalid/utxo 数值并不阻止其它链被认为有效,它只是说 “软件以前验证过 这一条链”。

好的,那么,也许在理论上存在某种等价,但 assumeutxo(与 assumevalid 相比)有任何 实际上 的安全性差异吗?

是的,两者有一个实践上的安全性差异。当前,如果我想要欺骗别人,让他们认为我拥有某一笔钱(而诚实的网络会看穿我),我需要这样所:

  • 让他们启动带有坏的 -assumevalid= 参数的 bitcoind,
  • 将他们的节点跟诚实的网络隔开,从而阻止他们看到具备最多工作量证明的区块头链,然后
  • 构造一条具备有效 PoW 的链(兼容现有的检查点),并且在最后一个检查点之后的某个区块中,将一笔资金分配给我

这显然要付出许多精力,因为攻击者需要生产一条具备有效 PoW 的区块链。

然而,在 assumeutxo 中,只要我让用户接受一个恶意的 assumeutxo 成熟数值,大部分工作就完成了。修改和序列化错误的 UTXO 快照是非常简单的 —— 并不需要工作量证明。

这听起来非常糟糕 —— 所以攻击者所需的不过是让一个用户接受一个坏的 assumeutxo 数值,然后给 TA 提供一个污染过的快照就好了?

没错,确实如此。

因此,assumeutxo 数值需要嵌进源代码中,并且我们不会开发一种通过运用命令行来指定 assumeutxo 数值的机制了;这在实践中的风险太高了。如果用户希望指定另一个数值(不推荐这样做),他们可以修改源代码然后重新编译。

听起来这是要主张在某个地方(比如说区块头)包含一个对 assumeutxo 数值的承诺,从而让共识可以保证它。我们应该这么做吗?

也许未来可以这么做,但不是现在。在我们获得使用 UTXO 快照的实际经验之前,我们不知道正确的承诺结构是什么样的。改变共识是一个非常昂贵的过程,而且除非我们绝对确定希望承诺它,不然就不该尝试。

沿着这条路一直走下去,也许我们会在共识关键的地方引入这样的承诺,但目前,我们应该将 assumeutxo 设计成不需要这样的假设也是安全的。

对导入的快照,除了比较它的哈希值与 assumeutxo 数值,我们还会运行别的验证吗?

是的。在 UTXO 快照加载完成、同步到网络的链顶端之后,我们会在后台运用单独的数据结构(即一个单独的 chainstate)启动一次初始化区块下载。这个后台的 IBD 会一直下载和验证所有区块,直至被快照假设有效的最后一个区块(即该快照的 “基础”)。

一旦后台的 IBD 完成,我们就验证完了在我们加载快照时候假定有效的所有历史区块。然后,我们就可以丢弃后台验证后得到的 chainstate 数据了。

资源使用情况

我们用在后台 IBD 中的额外 chainstate,不会占用额外的硬盘空间和内存吗(它会使用单独的数据库和缓存)?

没错,因为我们要维护两个完全独立的 UTXO 集,以支持后台 IBD(这会同时使用快照假定有效的链条以及快照基础高度后的链条),我们必须拥有一个额外的 CCoinsView* 层级。这意味着要在硬盘上临时保存一个额外的 chainsate(leveldb)目录,而且需要根据每个 -dbcache 分割内存、分配给需要放在内存内的 UTXO 缓存。

我不认为这是一个很大的问题,因为这基本上意味着(在当前来说)额外的 3.2GB 的存储空间。我们可以按照大约 80/20 的比例给 后台 IBD 所用的 CCoinsViewCache vs. 假定有效的 chainActive 分配指定的 -dbcache 内存,因为较大的 dacache 仅在 IBD 期间才提供了重大的性能好处。

我们还应该运行后台验证同步吗?如果我们接受了 assumeutxo 的安然模式,为什么还要执行后台 IBD?IBD 不是一个长期的扩展问题吗?道理在哪?

如果我们引入 assumeutxo 和快照而不在后台运行 IBD,那么容易想象几乎每个建立节点的人都会使用 UTXO 快照(因为它比传统的 IBD 快得多)、假定某一段区块链是有效的,然后将自身作为剪枝节点(pruned node)呈现给网络。在极端情况下,这将导致缺乏节点为网络提供历史区块。这显然不是我们想要的,因此,将后台 IBD 作为默认设置似乎是有意义的。

硬件受限的用户无疑可以在剪枝模式下使用 assumeutxo 。

Assumeutxo 是一项性能优化。如果我们移除 IBD 模式、换成它,就改变了比特币的安全模型。在未来,我们可能会分割区块下载与 连接/验证 程序,从而 assumeutxo 节点依然可以为对等节点提供区块,而无需花费计算资源来执行 IBD 式的验证。

用户和审核者该如何高效地验证给定一个 UTXO 集的哈希值?

当前,计算特定高度的 UTXO 集的哈希值可以使用 gettxoutsetinfo RPC 命令来做到(即 GetUTXOStats())。这会花掉几分钟来计算;如果你要对某一个具体的高度计算这个值,你需要调用 invalidateblock 来回滚到那个高度;计算完成后,你可以使用 reconsiderblock 快速切换回来。显然,这会打断正常的操作。

这不方便,但我们可以修改 gettxoutsetinfo 的行为,使之可以接受一个高度作为参数,然后至少抽象掉通过 invalidateblock/reconsiderblock 手动回滚和切换的过程。

未来,可以想象我们可以使用一个节点本地的滚动 UTXO 集哈希值以让特定高度的 UTXO 哈希值随时可用。但是,滚动的 UTXO 集哈希值跟 assuemeutxo 承诺方案是不兼容的,因为后者涉及到分块快照(下文有述),并且因此最终的 assumeutxo 值可能必须是 (rolling_set_hash, split_snapshot_chunks_merkle_root) 这样的元组。

快照的存储与分发

快照如何分发?

最终,用户将通过对等节点网络来获得 UTXO 快照。但在这个状态实现之前,用户需要从其他人或者 CDN 那里说得 UTXO 快照,然后用硬编码的 assumeutxo 哈希值来验证它(见下文的 “如何分步部署?”)。

因为快照的体积是非常大的,而且因为恶意的对等节点可能会谎称自己提供的一个大文件是一个有效的快照,所以我们需要分块存储及传输。应该做到容易验证每一块。

粗糙地说,分切的一种办法是将快照平均分成 n 块。我们可以用每一块的哈希值构造一棵默克尔树,然后将树的根植作为嵌入源代码的 assumeutxo 承诺数值。每一个对等节点都会选择随机的数值对 k 求模(这里 k <= n),以确定自己要复杂存储哪一部分数据块。在初始化的时候,想要获得快照的对等节点将需要找出 k 个不同的对等节点,每一个对等节点都提供快照数据的独特一 “段”,从而获得全部 n 个数据块。

这听起来很棒,也简单,但我打赌某个地方还有问题。

没错。这种方法的问题在于:

  • 找到能够提供所有 k 段数据的节点集合是不便利的,而且
  • 这开启了一个微小的 DoS 界面 —— 为了阻止初始化,攻击者只需针对提供某一段数据的所有节点。

相应的,我们可以使用纠删编码,生成 n+m 个数据块(其中 m 就是额外编码的数据块的数量),并让冷启动的节点才对等节点处检索任意 n + 𝛼 个不同数据块;这里的 𝛼 的大小取决于我们所用的具体的编码方案。每一个节点都依然只存储和提供快照全部分块的子集。

一个节点要存储多少套快照?

为了保证运行旧版本软件的邻居,一个节点只存储最新的快照是不够的。具体要存储多少套可以讨论,但我认为,为两套历史快照以及最新的一套快照存储一些数据,是合理的。

对于每一套快照,每个节点都只需存储一部分数据(大概是 1/8 左右),取决于我们为纠删编码方案选择的具体参数。

一个参考,当前的一套 UTXO 快照大概是 3.2 GB。如果我们不做任何改进、快照保持这个规模,那么预计一个节点要存储 1.2GB(= 1/8 * 3 套快照 * 3.2GB/每套快照)的快照数据(假定需要 8 个对等节点来使用快照冷启动,并且每个节点都存储 3 套快照)。

对于刚刚发布的新的 assumeutxo 值,相应的快照如何获得?

好问题,我还没有想得很清楚。可以假设,我们可以(比如说)让代码每 6 个月生成一套快照。为了能在运行时生成快照而不影响正常过的操作(比如新区块的响应),我们可能必须重构 “从状态到磁盘” 的刷新,使之变成异步的。

Sjors 指出,我们可以基于区块高度,周期性地生成快照,这可能会派上传输给对等节点以外的用场:

如果按固定的区块间隔生成,这些快照可以【马上】派上用场,即便(其承诺)还没有进入新发行的软件。它可以用于本地备份,从区块以及 chainstate 的数据中恢复。每个节点都可以用简单的 text 文件来存储哈希值。对于剪枝节点来说,重新编制索引可以通过回顾快照来实现,而不必再一路回溯到创世区块。类似地,这在一个节点需要撤销剪枝以扫描旧钱包的使用也是有用的。

其它方法

使用 UTXO 快照就必须存储和发送这些快照,为什么不让 Bitcoin 直接以 SPV 模式(例如使用 BIP 157),然后在后台 IBD 呢?

这是一个吸引人的想法,但在实践中有一些缺点。

首先,实现 assumeutxo 方法比起在 Bitcoin Core 中开发一个 SPV 模式,所需的代码要少得多。当前,Bitcoin Core 并没有任何作为 SPV 客户端的代码,而且许多子系统也都假设了一个 chainstate 的持久存在(即一个 CChain 区块链对象以及对 UTXO 集的视图)。

也许有些让人意外,但 assumeutxo 方法让我们可以复用大量 Bitcoin Core 中已经存在的代码。它的实现相当于一系列的重构(即使没有 assumeutxo,本来也是要做的,会让编写测试变得更加容易),再加上少量新的小块逻辑,以处理在初始化和网络通信期间多个 chainstate 并存的问题。

需要使用大量的新代码(比如 Reed-Solomon 纠删编码)来分割快照以存储和对等传输,但这类代码可以容易地放在系统的外围,而构筑 SPV 模式的代码则必须对软件的 “核心” 作不计其数的修改。

更多新代码就意味着更大的工程量和审核工作,而且最终会有更多风险。而且,使用 SPV 模式,也意味着在完整的链被下载和验证之前,节点无法对新进入的区块作全面的验证。

那为什么不使用一些不要求改变 Bitcoin Core 代码的方式呢?比如说,让某人提供 PGP 签名的数据集,然后你可以通过(比如说)btcpayserver 的 FastSync 来下载?

通过 Bitcoin Core 软件以外的方式来分发这类数据在实践中有一系列的缺点。如果个人和团体被鼓励下载一定程度上未经软件本身验证的 chainstate 数据,会引入许多安全风险。用户将不仅需要信任 bitcoind,还需要信任数据的提供方。除了软件本身以外,这些数据的提供方也需要挑剔。

此外,现有的方案,比如 FastSync,使用 PGP 来见证他们所提供的数据的有效性。这个签名常常被忽略,而且说服用户可靠地验证它们不是个容易的事。

Assumeutxo 或者类似方案的用户应该是 默认安全的,而且最终来说,用户不应该需要采取额外的步骤,才能从这样的优化中收益(同时保持安全性)。

计划

好的,assumeutxo 听起来很棒。怎么部署呢?

  1. 实现让多个 chainstate 能够同时使用所必需的变更。(见 PR
  2. 通过 dumptxoutsetloadtxoutset 实现 UTXO 快照的创建和使用,以及一个硬编码的 assumeutxo 哈希值。(见 PR
  3. 留出实践,让精通技术的终端用户可以通过 RPC 手动测试快照的用法。
  4. 研究高效的快照存储和分发方案。
  5. 实现并部署最终确定的 P2P 快照分发机制。
  6. 等待。
  7. 考虑支持 UTXO 集哈希值的共识变更是否有意义。

如果我想帮忙,接下来我应该做什么?

这里审核代码。因为变更的规模较大,一部分代码可能会被分割,大家会感谢你的投入。

assumeutxo 如何跟累加器UHS 以及 utreexo 一起工作(如果它们会到来的话)?

如果其它更节约空间的 UTXO 集表示方式可用,它们会跟 assumeutxo 相得益彰。容易想象 assumeutxo 的值变成 utreexo 森林的默克尔根值,或者一个累加器数值。UTXO 快照将缩减成几千字节,而不是几 GB。后台 IBD 也依然会很有用,因为我们将依然希望在后台执行完全验证。

实现问题

应该如何在 假定有效的状态 和 后台状态 的验证之间分配内存(-dbcache)呢?

来自 Sjors:

我认为,应该分配绝大部分内存给快照状态,使之追上链顶端。因为快照可能代表的是 6 个月以前的链状态。一旦追上,就刷新并分配绝大部分内存给后台验证(从创世区块同步到快照基础区块)。如果节点在此期间重启,那就同步区块头,如果落后超过 24 小时,那就再次分配大部分资源以追上链顶端,否则分配大部分资源以追上快照基础区块。

在最初的版本中,至少我们需要确保两个 UTXO 集对内存的用量不超过 -dbcache + -maxpool

此时,实现草稿会在后台验证与顶端追赶之间按 70/30 的比例分配内存。

为什么用通过 RPC 来加载快照?不应该是一条启动命令吗?

将快照加载作为启动参数,的确可以简化许多东西(比如链状态数据结构的管理),但如果我们最终要走向可以通过 P2P 网络来传输快照,我们就需要准备好让快照能在启动之后加载的逻辑。我认为,我们应该在将这个特性作为默认配置之前确保充分测试,因此,loadtxoutset 似乎是在 RPC 阶段应该使用的方法。

(完)