问题
在现代区块链中,如果某个节点想要验证一个区块,它要么必须是一个完整的节点来存储整个网络状态,要么就必须不断地向一些远程存储器索取其中的各个部分。这些解决方案中的每一个都拥有不便之处(存储100多GB的数据)或风险(存储装置伪造它发送给你的数据)。
我为一个可以验证区块的轻型客户端提出一个适当的解决方案--AVL+树。它不需要存储N ,而是要求客户端存储1个哈希值(唯一标识状态),并在每个交易或区块中收到一个log(N) 大小的证明。客户端可以验证该证明是由客户端根哈希所指的相同状态生成的。这使得在智能手机和其他无法存储完整状态的设备上可以实现一个验证性的轻客户端。
什么是AVL+树?
AVL+树是一个键和值之间的映射。它能够证明在其上进行的操作。证明本身是任何人唯一需要的东西,以重放操作并以与你相同的结果结束,本质上验证你没有作弊。
每个AVL+树都有一个唯一的根哈希值。如果这个哈希值足够加密,你可以把它看作是一棵树的那个特定状态的 "名字"。这意味着,你可以发送一个可证明的操作,如下。(operation, proof, endHash),其中endHash 是你最后的根散列。
"AVL "是其发明者的名字的缩写。Georgy Adelson-Velsky和Evgenii Landis。
"融化 "与它有什么关系?
来自实现的树只是部分保留在内存中;你可以强迫它随意 "融化 "到底层存储中。它将逐渐具体化,只有操作中实际需要的节点。
它可以如何使用?
它可以用在区块链中,将你验证一个交易所需的数据量(存储在客户端)减少到一个哈希值。
然而,它增加了交易的重量,因为每个要证明的对象都携带O(log(N))-大小的数据,其中N 是地图中的键的数量。
开发历史
如果你在任何时候感到厌烦,就去看下一节。它包含了大部分的技术细节,经过了压缩。
起初,我被赋予的任务是构建一个AVL+树。它本质上是一棵AVL树,而AVL树又是一棵平衡的二进制树,它在叶子节点中存储其键和值,在分支中只存储技术数据。"+"意味着每个节点都有它的哈希值计算并存储在里面,而节点的哈希值取决于两个子节点的哈希值。通过归纳,这意味着树中任何节点的任何变化都会改变根哈希值,因此--通过哈希值的属性--证明根哈希值可以识别状态。
对于那些感兴趣的人来说,存储在节点内的哈希值本身不能在其自身的计算中使用。树的用户选择了散列算法,有一个保护层可以完全防止这种情况。
在理论专家的帮助下,我们发现我们不能简单地在每个子树中存储每个节点的高度,因为这需要我们在检查平衡时访问两个子节点,诱发低效率。相反,我们决定存储高度差--或者我怎么叫它--tilt 。平衡树的任何节点的tilt ,只能来自[-1, 0, 1]这个集合。
我还被告知,树应该在每个操作上产生证明(insert,delete, 甚至lookup )。我们需要以某种方式将前一个和后一个密钥纳入证明中,这样,对于删除一个不存在的密钥,接收者可以通过查看周围的密钥来检查该密钥是否真的不存在。
证明(在我读到的所有资料中)是 "哈希链 "+修改的叶子节点。所以,如果你改变了一些节点,证明会是这样的。

在这幅图中,我们改变的唯一路径是叶子,所以我们把子树的细节sprout1-3 。我们唯一需要的是他们的哈希值。
上图中的证明被表示为
( [ (sprout1, L, info1)
, (sprout2, R, info1)
, (sprout3, L, info1)
]
, leafBefore
, newHash
)
其中info1-3 是节点的其他技术数据。
首先,你需要证明它与你的根哈希值相同。为此,你必须做以下工作:
oldHash’ = foldr recomputeHash (hash leafBefore)
[ (sprout1Hash, L, info1)
, (sprout2Hash, R, info2)
, (sprout3Hash, L, info3)
]
where
recomputeHash (sideHash, _, info) rightHash = hash(sideHash + hash info + rightHash)
然后,你必须检查你从该计算中得到的oldHash’ 是否与你持有的散列相同。这个动作将证明这个证明所指的是与你的根哈希值相同的树。然后你对该证明上的叶子进行操作,重新计算它的哈希值,它应该等于newHash 。如果是这样,就认为这个操作被证明了。
让我印象深刻的第一件事是,这个证明看起来很像树本身。我应该把所有的操作写两次--为树和证明--还是应该写一次并重复使用?在那一刻,我的实现和文章中通常描述的第一个区别诞生了--我的证明是一棵修剪了不感兴趣的元素的树。我给我的ADT添加了一个树状状态:| Pruned hash 。
还有一个问题。当时,我把周围的键存储在每个叶子节点里面,因为有些实现是这样做的(他们声称这提高了可验证性水平)。这意味着在每次突变之后,我都需要去找邻近的键来修复它们的下一个和上一个键。递归insert ,看起来它不能做到这一点,也不能成为一个完整的混乱。
因此,我决定使用一个拉链来自由浏览树。
Zipper是一个 "功能性迭代器",它可以在一个递归树状结构中的子树上上下下,并进行批量的局部修改。当你退出Zipper时,所有的修改都被应用。
Zipper给了我descentLeft/Right (去子节点)、up (去父节点,应用所有的局部修改,也许还可以重新洗牌)和change (执行局部修改,安排一个节点重新洗牌)操作。
这是一个巨大的变化。zipper实际上只让我在up 操作中调用rebalance ,这隔离了重新平衡,使我有可能在所有其他情况下忘记这个问题。只有当源节点(或其任何一个转折性子节点)被changed,才会在up 操作中调用重新平衡。
由于我的证明是相同的树(但被切割),我必须以某种方式收集它们。我想过在遍历全树时收集被切割的树,但这看起来很难。所以我把这个任务一分为二。
首先,我收集我在操作过程中接触过的节点的哈希值。即使我只是读了这个节点--读的操作也要被证明。在我结束了拉链遍历之后,我将收集到的节点哈希值和一棵旧树送入修剪器,修剪器将我没有接触到的子树剪掉,产生一个证明。
所以,现在你不需要在证明上运行不同的insert ,你只需要从中得到你的树,然后在一棵完整的树上运行同样的insert 。
这也允许用一个证明来证明任何一批连续的操作,通过计算它们的节点哈希集的联合,并将结果反馈给剪枝器。
修剪只是对树的一个递归下降,对每个节点检查它是否在 "有趣 "的集合中。如果是,就保留它,并遍历它的子节点;如果不是,就用Pruned itsHash ,算法就不再继续下降了。修剪的结果被称为Proof 。
因此,最后,我能够把insert 、delete 、lookup 写成简单的操作,而不需要关心再平衡和(几乎)不需要关心证明。
在Disciplina项目中的应用
我和我们的技术负责人讨论了这个问题,他有一个想法,就是将树部分地存储在kv数据库中,而不是将整个树实体化。他还提议使用Free monad,而不是我当时的Fix-plumbing。这很顺利,最后我为store 和retrieve 行动的底层单体提供了一个简单的接口--代表一些普通的KV-数据库来保存(hash, treeNode) 对。Pruned 的构造函数被替换为Free 单体中的Pure 。我在Internal 层中写了一个加载器,它只对你需要的节点进行物化。我还做了一个save 操作,它将树与DB同步,返回其非物质化版本。这使得树能够 "融化 "并逐渐 "固化 "到数据库中。
在我们插入rocksdb作为AVL+下面的存储时,增加了一个功能:从存储中存储和恢复当前的根,以及两种变体的树保存:append 和overwrite 。
append 的工作方式是将树与数据库中的任何其他树一起写入,然后将根的指针改为新的指针。这使得从同一存储器中加载树的任何先前状态成为可能。overwrite 操作试图在存储当前树的分歧节点之前,删除以前的树的未共享部分(由根指针指向)。
在接下来的入侵中,我把 "下一个 "和 "上一个 "键从叶子中移除。原因是--我们可以在做lookup/insert/delete 的时候确定地导航到它们,没有必要存储它们的键,也没有必要通过每次更新这些键来使事情复杂。
我之前遇到的一个问题现在得到了妥善解决--当对同一节点进行多个操作时,对于每一个节点的哈希值都要重新计算。加密计算是很繁重的,所以有必要降低负荷。
解决办法很棘手--我让哈希值变得懒惰(我们应用了StrictData 语言扩展)。这样一来,哈希值将在请求时计算,而且只针对树的最后一个版本。这确实增加了内存消耗,每个节点+1个thunk,但该补丁的其他变化(-2个键(next/prev)和每个节点-1个Integer)是对这一点的很好补偿。
在节点变化跟踪方面做了一个小改进:我引入了一个布尔isDirty "变量"(在拉链状态中),而不是将节点哈希值与它之前的状态--存储在下降堆栈中--进行比较。这导致了一些虚假的测试失败,这些失败在10%的时间内发生,就这样消失了。
目前的技术状况
对于一个非常大的状态,实现支持树在内存中只被部分物化。
证明可以从任何变化序列中产生并应用于任何变化序列--对事务或整个区块。
为了证明区块,客户端不需要与区块的源头或完整状态的任何持有人沟通,验证所需的所有数据都包含在证明中。
证明时要插入的特殊存储也包含在同一个包中。
从用户和操作的角度来看,"物化 "和 "非物化 "状态是没有区别的。你只是有一个Map hash key value ,并在上面做事情。它可以与另一棵树进行平等比较,即使key 和value 类型不支持平等比较。
对于操作,它支持insert,delete,lookup,lookupMany, 和fold[If] 操作。每个操作都返回:一个结果、一个新的树和一个节点集。(是的,lookup 也返回一个新的树。树,其中访问的节点被物化了)。然后,你将所有的节点集连接起来,并将它们与初始的树prune ;这给了你一个证明,可以用来在其他一些客户端(包括一个轻型客户端)上重新运行同样的操作,以证明你之前所做的改变。
当前的树可以通过append 或overwrite 存储,然后通过currentRoot 恢复,这是一个O(1) 操作,以非物质化的方式返回树。
这棵树在执行了一些操作后,作为一个缓存,在append 和overwrite 上与存储同步 - 但使用overwrite 需要一个外部锁。你也可以直接把树扔出去,处理掉你所做的任何改变。
隐藏的拉链层负责处理再平衡和证明收集。尽管你仍然需要至少访问你想要的节点,以使它们在证明中结束,但这并不是一个问题:访问是领域操作的一部分。
结论
我已经提出了一个可以用于区块链的解决方案,使轻型客户端可以验证一个区块,而无需持有完整的状态或向服务器提出额外的请求。由于其存储方案,树本身可以安全地存储在其他地方,从而消除了另一种方式的轻客户端实现的问题。该树也能够处理不适合在内存中的状态。