unitimes.io
全球视角,独到见解
编者注:本文建议与《Vitalik:以太坊 Serenity 设计依据综述》对照阅读,mix 一下,效果更佳。
尽管以太坊借用了许多已经在诸如比特币这样的旧加密货币中试用并测试了五年的想法,但是以太坊中也有许多地方不同于当前处理特定协议特性的常见方式。此外,以太坊不得不开发一种全新的经济方法,以提供其它现有系统无法提供的特性。本文档旨在详细说明在构建以太坊协议的过程中所产生的细微甚至被忽视又或者是在某些情况下有争议的决策,并揭示这些方法和潜在的替代方案所涉及的风险。
01
原则
以太坊协议的设计过程遵循以下几个原则:
1、多层次复杂性模型(Sandwich complexity model):我们认为以太坊的底层架构应该尽可能简单,并且以太坊的接合点(包括面向开发者的高级编程语言以及面向用户的用户界面)应该尽可能易于理解。如果复杂性不可避免,那么它应该被置于协议的“中间层”。这里的“中间层”不属于核心共识的一部分,同时也无法被终端用户的高级语言编译器看见,包括参数序列化、反序列化脚本、存储数据结构模型、leveldb存储接口和电报协议(wire protocol)等。然而,这种偏好并不是绝对的。
2 、自由:用户可以使用以太坊协议做任何事情,我们不应根据其目的的性质来选择优先支持或不支持某种类型的以太坊合约或交易。这类似于“网络中立”概念背后的指导原则。当前比特币交易协议并不遵循这一原则,其不鼓励用户将区块链用于“未标示用途(比如数据存储、元协议等)”,并且在某些情况下限定明确的准协议变化(比如OP_RETURN限定为40个字节)以应对作恶者通过“未授权”方式使用区块链来攻击应用程序的意图。在以太坊中,我们强烈倾向于以与经济激励大致对等的方式来设置交易费用,从而使意图阻塞或妨碍区块链的用户的活动成本内部化(即庇古税收) 。
3、通用化:在以太坊中,其协议特性和操作码所包含的概念应该尽可能偏底层,以便它们可以以任意方式组合,包括那些当下看来毫无用处但到后来又可能有用的组合。如此一来,我们便可以在不必要时剥离某些功能,从而可以提高这些底层概念的效率。遵循这一原则的一个例子是,我们选择LOG操作码作为向(尤其是轻客户端)dapp提供信息的方式,而不是像之前在内部提议的那样仅仅简单地记录所有交易和消息——“消息”这一概念实际上是多个概念的集合,包括“函数调用”和“外部观察者感兴趣的事件”,我们认为将这两者分开是很有必要的。
4 、我们没有所谓的特色:通用化的必然结果是,我们拒绝将非常常见的高级用例构建为协议的内在部分。我们认为,如果人们真的想要这么做,那么他们可以在合约内部构件一个子协议(例如由以太币支撑的子货币,比特币/ 莱特币 / 狗狗币侧链等)。一个关于这一方面的例子是以太坊内不存在类似于比特币的“锁定时间(locktime)”功能,因为这样的功能可以通过某种协议进行模拟。在这种协议中,用户发送“已签署的数据包”,并且这些数据包可以被送入专门用以处理这类数据包的合约中。如果在某种特定的合约层面上而言,这些数据包是有效的,那么合约将执行相应的功能。
5、非风险厌恶:如果某种可能引入高度风险的变更能够提供非常可观的收益(比如通用的状态转换、出块时间缩减50倍、共识效率等),那么我们可以忍受这种风险。
这些原则对以太坊的开发具有相当的指导意义,但它们并非是绝对的。在一些情况中,由于我们希望减少开发时间或者不希望同时尝试太多激进的东西,某些变更,甚至包括一些明显有益的变更都被延迟到后续(例如在以太坊1.1)再行发布。
02
区块链层面的协议
本节介绍了在以太坊中所进行的一系列区块链协议更改,包括区块和交易的运作方式、数据的序列化和存储方式以及帐户模型背后的机制。
03
为什么选择帐户模型
在比特币及其衍生币中,用户余额数据都存储在基于未花费交易输出(UTXO)的结构中:系统的全局状态包含一组“未花费输出”(即“币”)。如此一来,每一个币都归属于一个所有者并拥有一个值,而每一笔交易在花费一个或多个币的同时,又同时创造出一个或多个币,但这个过程必须符合有效性限制:
每一个被引用的输入必须有效且未被花费
该笔交易必须包含与每个输入相匹配的所有者的签名
输入的总值必须等于或大于输出的总值
因此,在比特币系统中,用户的“余额”实际是用户所拥有的能够给出有效签名的私钥所对应的所有币的总值。
以太坊舍弃了这种方案,并采用了一种更简单的方法:让状态存储一个帐户列表(其中每个账户都有余额)以及与以太坊相关的特定数据(代码和内部存储)。如果交易发送方的帐户有足够余额用于支付款项的话,那么这笔交易就是有效的。在这种情况下,发送方及接收方两方账户将遵循借贷记账法进行价值转移。如果接收方帐户拥有代码,并且代码运行,那么其内部存储也可能被改变,或者该代码可能向其它帐户创建额外的消息,从而产生进一步的价值转移。
使用UTXO模型的好处是:
1、更高的隐私程度:如果一个用户每接收一笔交易就使用一个新的地址,那么外人通常很难将这些帐户相互联系。这一特点使得以太币在很大程度上非常适合作为货币,但不太适用于所有dapp,因为dapp通常需要跟踪与用户相捆绑的复杂状态,并且可能不存在像货币那么简单的用户状态分区方案。
2、潜在的可扩展性范例:在理论上,UTXO与某些类型的可扩展性范例更加兼容,因为我们只能依赖特定的币的所有者来维护一个关于所有权的默克尔证明。此外,即使包括所有者在内的所有人都决定遗忘这些数据,最终受害的也只有币的所有者。在一个帐户范例中,如果每个人都失去了与该帐户相对应的默克尔树的特定部分,那么他们将无法以任何方式处理任何能够影响该帐户的消息,包括给该账户发送消息。然而,现实中还存在有不依赖于UTXO的可扩展性范例(这句话不太好理解)。
使用帐户模型的好处是:
1、节省大量空间:举个例子,如果一个帐户拥有5个UTXO,那么它从UTXO模型切换到帐户模型所需耗费的空间将会从 (20 + 32 + 8) * 5 = 300 字节(地址耗费20字节,txid耗费32字节,值耗费8个字节)减少到20 + 8 + 2 = 30个字节(地址耗费20字节,值耗费8个字节,nonce值(即随机数,下同)耗费2个字节(详见下文))。事实上,由于帐户需要存储在帕特里夏树中(详见下文),因此这一部分所节省的空间并没有这么大,但也不小。此外,交易所需的空间变得更小(比如,在以太坊中只需要100字节,而在比特币中需要200到250字节),因为每笔交易只需创建一个引用以及一个签名,然后产生一个输出。
2、更好的可替代性:由于这里不存在区块链层面的概念来区别特定的币集合的来源,因此,无论从技术上还是从法律上而言,根据币的来源来区分出特定的币,并为之制定红名单/黑名单方案都是不可行的。
3、简单性:更容易编码和理解。尤其在涉及复杂脚本的时候,这一特点更加明显。尽管我们可以强行将任意去中心化应用程序套入UTXO范例中,比如让脚本能够限制特定UTXO所允许花费的UTXO类型,并且要求花费行为包含该脚本所评估的应用状态根变化的默克尔树证明,但相比起账户范例,UTXO范例要更加复杂、更加简陋。
4、持续的轻客户端引用:轻型客户端可以通过向下扫描特定方向的状态树来随时访问与帐户相关的所有数据。在UTXO范例中,这些引用数据会随每一笔交易而发生变化,这对于尝试使用上述UTXO状态根传播机制的长期运行的dapp来说,将是一个特别棘手的问题。
综上所述,考虑到我们所要面对的dapp将包含任意状态和代码,我们认为帐户模型的好处远大于其它替代方案。此外,根据“我们没有所谓的特色”原则,如果人们确实很重视个人隐私,那么我们可以通过合约内的已签署数据包协议来构建混合器(mixer)以及混币(coinjoin)方案。
帐户范例有一个弱点,那就是为了防止重放攻击,每一笔交易都必须拥有一个“nonce值”,以便帐户能够跟踪已被使用的nonce值,并且仅接受继上一个被使用的nonce值之后当前nonce值为1的交易。这意味着,即使从此不再会使用的帐户也永远无法从帐户状态中移除。一个解决此问题的简单方案是要求交易包含一个区块编号,从而使其在一定周期后不可重放,并在每个周期内重置一次nonce值。矿工或其他用户需要通过去“ping”不再使用的帐户才能将其从状态中删除,如果我们将整体扫描作为区块链协议本身的一部分,那么这个成本实在是太高昂了。为了加快以太坊1.0的开发速度,我们没有采用这种机制;但以太坊1.1及后续的版本可能会使用这样的机制。
04
默克尔帕特里夏树
默克尔帕特里夏树(简称MPT,又叫trie,为方便起见,如无特殊情况,下文中trie一律用“树”指代),最早由Alan Reiner设想并在Ripple协议中实现,将作为以太坊的主要数据结构,并用于存储所有帐户状态以及每个区块中的交易和收据。MPT是默克尔树[1]和帕特里夏树[2]的结合体,通过吸纳二者的元素来创建一个同时具有以下两种属性的结构:
每一个唯一的键/值对都唯一地映射为一个根哈希,并且不可能通过欺骗手段成为树的一部分(除非攻击者拥有大约2^128算力)
我们可以在对数时间内对键/值对进行更改,添加或删除操作
这一结构使得我们能够提供一种高效且易于更新的全局状态树“指纹”。关于以太坊MPT的正式阐述可以参阅:https://github.com/ethereum/wiki/wiki/Patricia-Tree
MPT中的具体设计决策包括:
1、拥有两类节点,kv节点和发散节点(更多详情请参阅MPT规范)。kv节点的存在提高了效率,因为如果在特定区域中,树是稀疏的,那么kv节点可以作为一条“捷径”,从而使得我们无需保存深度为64的树。
2、让离散节点以十六进制而非二进制进行运作:这样做是为了提高查找效率。我们现在认为这一选择的效果并不理想,因为我们可以通过一批存储节点在二进制范例中模拟十六进制树的查找效率。然而,由于这一二进制树结构很容易实现错误并最终导致状态根不匹配,我们决定将这一重组方案推迟到1.1版本再议。
3、空值和非成员之间没有区别:这是基于简单性的考虑,这种方案与以太坊的默认设置兼容得更好,这种默认设置是指:未设置的值(例如余额)通常按零处置,而零则由空字符串进行表示。然而,我们注意到这一做法会损害到一部分的通用性,因此效果并非最优。
4、终止节点和非终止节点之间的区别:从技术上讲,“节点终止”标志是不必要的,因为在以太坊中,所有树都用于存储静态的密钥长度。但我们还是加入这一属性以提高以太坊的通用性,并希望以太坊MPT的实现能够原封不动地用于其它加密协议。
5、使用 sha3(k) 作为“安全树”中的密钥(用于状态和帐户存储树):通过将处于不利地位的离散节点链条的深度最大值设置为64层,并不断调用SLOAD和SSTORE,我们可以让针对树的DoS攻击更加困难。需要注意的是,这一方案也会使对树进行枚举变得更加困难。如果你想要客户端拥有枚举功能,最简单的方案就是去维护一个数据库映射sha3(k) -> k。
05
RLP
RLP(“递归长度前缀”)编码是以太坊所使用的主要序列化格式,我们可以在任何地方看到,比如区块、交易、帐户状态数据和电报协议消息。 RLP的正式阐述请参阅: https://github.com/ethereum/wiki/wiki/RLP
RLP旨在成为高度简约的序列化格式,它唯一的目的就是存储嵌套字节数组。与protobuf [3],BSON [4]以及其它现有的解决方案不同,RLP不会尝试定义任何特定的数据类型,比如布尔类型、浮点型、双精浮点型甚至整型。相反,它仅仅以嵌套数组的形式来存储结构,并将其留给协议来确定数组的含义。这里没有明确支持键/值映射,如果你想要支持键/值映射,那么半官方建议是将这些映射表示为[[k1, v1], [k2, v2], ...],其中k1,k2 ...依照字符串的标准排序进行排序。
RLP的替代方案是使用现有算法,例如 protobuf 或 BSON。但是,我们更倾向于RLP,因为它(1)实现简单,(2)在字节层面保证绝对完美的一致性。在很多语言中,键/值映射都没有明确的排序,并且浮点格式有许多特殊情况,这些情况可能会导致相同的数据拥有不同的编码结果,从而产生不同的哈希。通过在内部开发协议,我们可以确信协议的设计遵循这些目标(这是一个通用的原则,也适用于代码的其它部分,例如VM)。需要注意的是,BitTorrent所使用的bencode为RLP提供了一个还不错的替代方案——尽管它在长度方面使用了十进制编码,这使得它略次于二进制RLP。
06
压缩算法
电报协议和数据库都使用自定义的压缩算法来存储数据。我们可以将这一算法描述为“对零进行行程长度编码,并使其它值保留原样”。当然,这里面存在一些关于常见值的特殊情况,比如sha3('')。举个例子:
>>> compress('horse')
'horse'
>>> compress('donkey dragon 1231231243')
'donkey dragon 1231231243'
>>> compress('\xf8\xaf\xf8\xab\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xbe{b\xd5\xcd\x8d\x87\x97')
'\xf8\xaf\xf8\xab\xa0\xfe\x9e\xbe{b\xd5\xcd\x8d\x87\x97'
>>> compress("\xc5\xd2F\x01\x86\xf7#<\x92~}\xb2\xdc\xc7\x03\xc0\xe5\x00\xb6S\xca\x82';{\xfa\xd8\x04]\x85\xa4p")
'\xfe\x01'
在压缩算法加入之前,以太坊协议的很多部分都存在许多特殊情况。例如,sha3经常被重写,从而使得sha3('') = ''(因为这样可以避免存储账户中的代码以及其它存储信息,最终节省64字节)。然而,最近我们进行了一些更改,将所有这些特殊情况全部移除。这意味着,在默认情况下,以太坊的数据结构会更加庞大。此外,我们还通过将数据保存功能置于电报协议中并将其无缝插入用户的数据库实现中来把数据保存功能添加到区块链协议之外的层中。这种方法增加了以太坊的模块化特性,简化了共识层,并且还提高了持续升级到即将部署的压缩算法的便利性(例如通过网络协议版本更新来进行)。
07
树的用法
警告:本节假设读者已经了解布隆过滤器的工作原理。相关介绍请参阅http://en.wikipedia.org/wiki/Bloom_filter
在以太坊区块链中,每个区块头都包含指向三个树的指针:状态树,表示获取特定区块后的全局状态;交易树,表示由索引键入的区块中的所有交易(即key 0:将要执行的第一笔交易;key 1:第二笔交易等,依此类推)以及收据树,表示与每笔交易相对应的“收据”。交易的收据是一个经过RLP编码的数据结构:
[ medstate, gas_used, logbloom, logs ]
其中:
medstate是处理交易后的状态树根
gas_used是处理交易后所花费的 gas 数量
logs是在交易执行(包括主调用和子调用)期间由 LOG0 ... LOG4 操作码所生成的项目列表,其形式为[address, [topic1, topic2...], data]。 address是生成日志的合约的地址,它的主题最多为4个32字节的值,并且它的数据是任意大小的字节数组。
logbloom是一个布隆过滤器,由交易中所有日志的地址和主题组成
区块头中还有一个布隆过滤器,它是该区块内的交易的所有布隆过滤器的逻辑或(OR)。使用这种结构的目的是让以太坊协议在尽可能多的方面对轻客户端友好。想了解更多关于以太坊轻客户端及其用例的信息,请参阅https://github.com/ethereum/wiki/wiki/Light-client-protocol#principles的原则部分。
参考注释:
[1]默克尔树:https://en.wikipedia.org/wiki/Merkle_tree
[2] 帕特里夏树:https://en.wikipedia.org/wiki/Radix_tree
[3] protobuf:https://developers.google.com/protocol-buffers/docs/pythontutorial
[4] BSON:https://bsonspec.org/
[5] GHOST:https://eprint.iacr.org/2013/881.pdf
[6] Decker和Wattenhofer 2013年在苏黎世所撰写的论文见:
https://www.tik.ee.ethz.ch/file/49318d3f56c1d525aabf7fda78b23fc0/P2P2013_041.pdf
本文翻译:喏呗尔
原文作者:Vitalik Buterin
原文链接:https://github.com/ethereum/wiki/wiki/Design-Rationale
【文章版权归原作者所有,其内容与观点不代表Unitimes立 场。转载文章仅为传播更有价值的信息,合作或授权联系请发邮件至 editor@unitimes.media或添加微信unitimes2017】