以太坊
以太坊诞生于2014年。2014年,19岁的Vitalik Buterin率先在一片文章中提出针对比特币的改进以及对区块链未来的构想。这篇文章中提出可以针对比特币的脚本验证中所使用的栈虚拟机做出改进,以建造一个图灵完备的虚拟机。这样,区块链中的虚拟机将不再局限于脚本的验证,而是可以执行任何计算机能完成的程序。这样的区块链可以作为其他区块链应用的载体,还可以支持一条链上不同区块链应用之间的互相操作。这个想法得到了Gavin Wood的支持,并且在同年2014年上半年,Gavin Wood发表了一份详细的关于新区块链的技术规范,并且在2014年下半年,Gavin Wood用C++开发出了以太坊的第一个客户端。
以太坊不仅维持了一套支付的电子货币系统,还有一整套于其上建立的软件生态。我们把以太坊中运行的虚拟机叫做EVM,把在EVM上运行的程序叫做智能合约。以太坊所有设计,都是围绕智能合约展开的。智能合约能做到很多原生计算机程序能做到的事情:例如自动支付、代币发行、数字资产转移、版权保护等的功能。
以太坊诞生至今,经历了多次迭代更新。以太坊第一个正式的客户端于2015年发布,2016年,以太坊上的热门智能合约TheDAO由于代码漏洞,被黑客攻击。黑客利用漏洞盗取了TheDAO合约中的以太币,这些以太币占总以太币发行量的5%。这次攻击使得以太币价格大跌。以太坊社区为了补救这次攻击,于是集体投票决定回滚以太坊主链。但回滚的决定并没有收到所有人的支持,仍有一小部分人决定再旧链上继续挖矿。这使得以太坊社区分裂为两个社区,一个叫ETH,另一个叫ETC。之后,以太坊从2020开始实验使用PoS算法代替PoW算法,并且之后于2022年,由传统的PoW工作量证明机制全面转换为PoS权益证明机制,解决了区块链能源浪费的问题。以太坊至今仍在经历重大迭代更新,预计在近年,完成对网络的扩容升级,增加整个网络处理交易的吞吐量。

用户层
与比特币不同的是,以太坊没有使用UTXO作为其交易模型,而是采用传统的记账方式,在账本上直接记录每个人的账户余额,这种交易模式与现代银行转账系统类似。这种基于账户交易模式相比于UTXO,失去了UTXO中能追踪每一笔转账源头的特性,但是这样记账简单明了,符合大多数人的直觉。用户在查阅自己的账户余额时,不需要浪费时间挨个审计每个区块中与自己有关的交易。同时,也和现代银行系统类似的是,以太坊要求身份和账户一一对应。尽管以太坊允许一个人持有多个账户,但是用户在转账或者参与智能合约时,只能使用确定的账户。例如,用户再参与竞标类的智能合约时,不能使用以一个账户竞价,而使用另一个账户付款或者退款。以太坊中的交易账户是非匿名的,以太坊中的转账需要在交易中明确表明转账地址,而不是添加一个没有明确指向的锁定脚本。
在攻击UTXO系统时,我们可以使用双花攻击,类似的,在攻击以太坊的交易模型时,我们可以使用重放攻击。例如,Alice给Bob转账了100元,那么Bob可以在网络内广播两次这笔转账。这样在验证者看来,Alice给Bob转账了两次,总计200元。为了防御这种攻击,我们在每个账户中添加一个字段nonce,这个字段代表账户交易的次数。这样每笔交易对应了唯一的nonce,攻击者就难以重放交易。
账户结构
以太坊中存在两类账户,一类叫做外部拥有账户(Externally Owned Account,EOA),账户由用户创建,账户内存储了用户的余额和nonce;另一类叫做智能合约账户(Smart Contract Account),这类账户没有公钥私钥,是由用户创建智能合约的时候自动创建的。智能合约账户也包含了余额和nonce,并且除了这些,还包含了智能合约的代码,以及供代码操作的存储空间。为了账户安全,以太坊硬性规定,合约账户不能主动发起交易,只能被EOA或其他合约调用。以太坊中,EOA账户的地址是由公钥哈希的来的,对公钥进行 Keccak-256 哈希运算 ,然后截取最后20个字节作为EOA账户的地址。合约账户的地址是由创造合约的账户地址和账户交易次数nonce得到。其具体计算过程为:先对EOA账户地址和其交易次数nonce进行RLP编码,例如['0x1234...abcd, 1'];接着,对编码结果进行Keccak-256 哈希运算;最后取哈希值的最后20个字节作为合约账户的地址。以下是go版本以太坊客户端go-ethereum的源码(https://github.com/ethereum/go-ethereum.git, Accessed on 2024/7/16):
1 | // StateAccount is the Ethereum consensus representation of accounts. |
其中Nonce是每个账户的交易次数;Balance是账户余额,是一个256位的无符号整数;而CodeHash是智能合约的哈希值。智能合约用Solidity语言编写,编写后,需要编译成EVM字节码才能运行在EVM上,这里的哈希值,其实指的是EVM字节码的哈希值。Root是指合约账户中存储的MPT Root,以太坊中所有与存储相关的数据都用MPT表示其状态。
交易层
改变 EVM 状态的交易需要广播到整个网络。 任何节点都可以广播在以太坊虚拟机上执行交易的请求;此后,验证者将执行交易并将由此产生的状态变化传播到网络的其他部分。
交易需要付费并且必须包含在一个有效区块中。 为了使本概述更加简洁,我们将另行介绍燃料费和验证。
所提交的交易包括下列信息:
<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">from</font>- 发送者的地址,该地址将签署交易。 这将是一个外部帐户,因为合约帐户不能发送交易。<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">to</font>— 接收地址(如果是外部帐户,交易将传输值。 如果是合约帐户,交易将执行合约代码)<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">signature</font>– 发送者的标识符。 当发送者的私钥签署交易并确保发送者已授权此交易时,生成此签名。<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">nonce</font>- 一个有序递增的计数器,表示来自帐户的交易数量<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">value</font>– 发送者向接收者转移的以太币数量(面值为 WEI,1 个以太币 = 1e+18wei)<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">input data</font>– 可包括任意数据的可选字段<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">gasLimit</font>– 交易可以消耗的最大数量的燃料单位。 以太坊虚拟机指定每个计算步骤所需的燃料单位<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">maxPriorityFeePerGas</font>- 作为小费提供给验证者的已消耗燃料的最高价格<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">maxFeePerGas</font>- 愿意为交易支付的每单位燃料的最高费用(包括<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">baseFeePerGas</font>和<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">maxPriorityFeePerGas</font>)
交易对象看起来像这样:
1 | { |
但交易对象需要使用发送者的私钥签名。 这证明交易只可能来自发送者,而不是欺诈。
Geth 这样的以太坊客户端将处理此签名过程。
示例 JSON-RPC 调用:
1 | { |
示例响应:
1 | { |
<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">raw</font>是采用递归长度前缀 (RLP)编码形式的签名交易<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">tx</font>是已签名交易的 JSON 形式。
如有签名哈希,可通过加密技术证明交易来自发送者并提交网络。
<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">data</font>字段
绝大多数交易都是从外部所有的帐户访问合约。 大多数合约用 Solidity 语言编写,并根据应用程序二进制接口 (ABI) 解释其<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">data</font>字段。
前四个字节使用函数名称和参数的哈希指定要调用的函数。 有时可以使用本数据库根据选择器识别函数。
调用数据的其余部分是参数,按照应用程序二进制接口规范中的规定进行编码。
函数选择器是 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">0xa9059cbb</font>。 有几个具有此签名的已知函数。 本例中合约源代码已经上传到 Etherscan,所以我们知道该函数是 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">transfer(address, uint256)</font>。
其余数据如下:
1 | 0000000000000000000000004f6742badb049791cd9a37ea913f2bac38d01279 |
根据应用程序二进制接口规范,整型值(例如地址,它是 20 字节整型)在应用程序二进制接口中显示为 32 字节的字,前面用零填充。 所以我们知道 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">to</font> 地址是 4f6742badb049791cd9a37ea913f2bac38d01279。 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">value</font> 是 0x3b0559f4 = 990206452。
交易类型
以太坊有几种不同类型的交易:
- 常规交易:从一个帐户到另一个帐户的交易。
- 合约部署交易:没有“to”地址的交易,数据字段用于合约代码。
- 执行合约:与已部署的智能合约进行交互的交易。 在这种情况下,“to”地址是智能合约地址。
布隆过滤器 Bloom Filter
布隆过滤器可以帮我们快速查找交易、筛选符合条件的区块,例如,我们要查询所有包含跟自己有关交易的区块,我们只需要查询区块头中的Bloom字段,就可以快速判断一个区块是否符合条件。但是布隆过滤器也有一些缺点,一是其不支持删除操作。每次删除集合中元素时,我们都需要重新计算一个新的Bloom字段,而不是在原先Bloom字段上做删减。二是布隆过滤器有一定假阳性的概率,即可能Bloom字段中表明区块中存在相关交易,但实际上该区块并不包含与自己有关的交易。但是布隆过滤器不存在假阴性,即布隆过滤器过滤掉的区块一定不包含相关交易。Bloom字段的生成算法如下:
- 初始化一个大小合适的二进制向量,例如一个256bit的向量,或者一个512bit的向量。
- 计算集合内元素的哈希值,并将元素映射在向量中的某个位置,并把向量该位置比特从0反转成1。
最后我们可以获得一个由0和1组成的向量。当我们想要验证某个元素是否存在集合中时,我们只需要计算元素的哈希值,并通过相同的映射规则找到布隆过滤器中对应的比特,如果该比特为1,则证明集合中存在该元素。当出现哈希碰撞的时候,多个元素可能会映射到布隆过滤器中的同一位置。如果我们要找的元素恰好和集合内元素出现哈希碰撞,就会导致布隆过滤器出现假阳性。为了解决这个问题,我们可以用个多个哈希函数计算同一个元素的哈希值,分别映射到数组内的多个位置。如果我们删除集合内的元素,我们不能直接删除布隆过滤器中相应的比特(将该比特从1调到0),因为集合内多个元素可能会映射到同一个比特,集合内其他元素也可能映射到这个位置。当然,我们可以将比特改为计数,但是那样布隆过滤器就失去了其简单性。
在以太坊的实际实现中,所有和布隆过滤器相关的函数都在Bloom9.go文件中。 在以太坊的实际实现中,布隆过滤器的长度为256字节,在计算布隆过滤器时,会将交易内容的哈希值映射到向量的中的三个位置:
1 | // bloomValues returns the bytes (index-value pairs) to set for the given data |
合约层
以太坊中最重要的功能就是执行智能合约。智能合约是用Solidity编程语言编写,运行在EVM上的一段代码。通过智能合约,我们可以实现一些能自动与以太坊中内容交互的功能,例如以太币自动转账,使用以太币的拍卖,或者发行基于以太币的新加密货币和NFT。Solidity语言是以太坊中官方指定专门用于开发智能合约的编程语言,由以太坊开发团队开发,其在语法上与JavaScript类似,并且内置很多与以太坊交互的接口。在开发后,开发者需要将Solidity语言编译成EVM字节码,然后向0x0这个特殊地址转账来部署智能合约。智能合约的字节码存在于交易的data域内。部署的智能合约会生成一个地址和智能合约账户,这个账户内包含了余额,nonce交易次数,智能合约的字节码,以及供智能合约使用storage。在部署后,以太坊网络中的其他人就可以通过给智能合约账户转账来调用智能合约内的函数。调用智能合约的交易不一定要附带任何以太币,调用者可以叫交易中转账金额设为0。但是调用者在调用智能合约时,必须支付一定的gas费用,以提供一定执行智能合约的劳务费给矿工。
gas费是以太坊中为了避免智能合约出限无限循环而规定的一项制度,任何人在调用智能合约的时候都需要支付一定gas费。智能合约中每一个指令的执行都需要消耗一定数量的gas费用,简单的指令例如加减乘除很便宜,但是复杂的指令路计算哈希值,存储会很贵。用户在发起交易时,需要设置交易头内gas limit字段,设置内容时一个gas费用,例如20000。矿工在收到交易后会将gas limit中设置的gas费用一次性扣除,然后随着每一个指令的执行,计算一定的gas费用。如果交易执行完后,还剩一些gas费,则返还到用户的帐户上;但如果执行中,gas费用不够,则回滚交易状态,并且不返还任何交易费用。不返还任何费用的设计是为了防止DDoS攻击,如果返还交易费用了,攻击者就可以在网络内发布大量的无用交易,照成网络的堵塞。同时区块头内也有一个gas limit字段,则个字段的意思与交易中gas limit字段中的意思大相径庭。交易头中的gas limit时为了限制每个区块内交易数量而设计的,其表示每个区块中所有交易的gas limit的总量的上限。以太坊中并没有像比特币中强制限制每个区块的大小不能大于1Mb,以太坊中矿工可以根据自己的需求调整每个区块的大小(区块头中的gas limit字段),每次可以上调或者下调
,最后网络中的(区块头中)gas limit会趋向网络中所有矿工觉得合理的水平的平均值。
以太坊中每个矿工都运行了一个EVM,矿工在收到创建智能合约的交易后,每一个矿工都会在本地创建一份智能合约(修改本地状态树的状态)。任何调用智能合约的交易也会被所有矿工执行一遍,同时矿工在执行时收取一定汽油费用。需要注意的时,矿工在收取汽油费时,只是在本地的状态树上修改。当某一个矿工在找到一个合法区块后, 矿工会将执行过的交易,交易的收据连带着区块头中状态树的根哈希值一起发布到网络中。此时其他矿工则需要回退到上一个区块的状态,从上一个区块的状态开始,重新执行一遍新区块内的交易,并将交易gas费转移到挖出新区块的节点的账户上。如果其他矿工在新区块发布之前执行过一些智能合约,则这些执行过的交易需要全部回滚,他们也得不到任何gas费用。
Solidity
1 | // SPDX-License-Identifier: MIT |
以上是一段Solidity代码,这段代码完成了一个竞拍的功能。这段代码部署后,网络内的任何人调用这个智能合约内的function bid() public payable函数参与竞价。当然,竞拍有一个最后期限,参与竞拍的人需要在竞拍结束前参与竞价。参与竞拍则需要向这个合约账户内不断转账,例如第一我出价30个以太币,则我需要给合约账户转账30个以太币,如果接着我想加价到50个以太币,则我需要再往合约账户内转账20个以太币。竞拍结束后,竞价最高的人获得奖品,而其他竞拍人的以太币原路返还。接下来我们将逐段拆解以上代码,来学习Solidity。
整个智能合约的第一句指定了Solidity的版本,在这里使用的Solidity语言的版本是0.8.0:
1 | pragma solidity ^0.8.0; |
接下来,我们定义了一个合约:
1 | contract SimpleAuction { |
Solidity中和合约于JavaScript中的Class类似,用contract关键字创建。每个合约都会有一个构造器函数,其固定名称为constructor。构造函数只会在合约被创建的时候调用一次。你可能注意到了,在这段代码中,我们在获取当前时间时使用的时block.timestamp,即包含当前交易这个区块的时间,而不是其他编程语言中常见的操作系统当前时间。这是因为智能合约的执行结果必须是稳定的,这意味着相同的输入和函数,在不同电脑上的执行结果必须是相同的。因此我们将智能合约放在EVM虚拟机上运行,屏蔽了操作系统差异带来的差异。即便是这样,我们在编程语言设计中,仍需要一些特殊的设计来保证执行结果的一致性。在Solidity中,我们不能像其他编程语言那样访问系统的信息(例如系统当前时间,操作系统),因为每个操作系统的信息可能是不一样的。但是Solidity也为我们提供了一些确定的可以访问的信息,例如关于区块的信息:
block.blockhash(uint blockNumber) returns (bytes32) |
查询给定区块信息,仅对最近256个区块有效,且不包括当前区块 |
|---|---|
block.coinbase (address) |
挖出当前区块的矿工地址 |
block.difficult (uint) |
当前区块难度 |
block.gaslimit (uint) |
当前区块gas限额 |
block.number (uint) |
当前区块号 |
block.timestamp (uint) |
当前区块时间戳, Unix时间戳 |
我们还可以访问一些关于关于智能合约调用的一些信息,这些也是确定的:
msg.data (bytes) |
这是调用数据的完整副本。在智能合约调用过程中,所有传递的参数都会被编码成字节数据,并包含在msg.data中 |
|---|---|
msg.gas (uint) |
这是当前剩余的gas。每次调用函数时,都会消耗一定量的gas,msg.gas代表当前函数调用还剩下多少gas。 |
msg.sender (address) |
这是消息的发送者的地址。在函数调用过程中,这个地址表示发起调用的账户或合约地址。 |
msg.sig (bytes4) |
这是函数签名的前4个字节。这4个字节是由函数名称和参数类型编码生成的哈希值的前4字节。 |
msg.value (uint) |
这是随消息一起发送的wei数量。 |
now (uint) |
这是当前区块的时间戳,即当前区块的时间戳。它是一个uint类型的数据,等同于block.timestamp。 |
tx.gasprice (uint) |
这是交易的gas价格。gas价格是发送者愿意为每单位gas支付的价格,用于激励矿工将交易打包进区块。 |
tx.origin (address) |
这是交易发起者的地址,即最原始的调用者。在合约调用链中,tx.origin表示最早发起调用的账户或合约地址。 |
接着在代码中,我们定义了合约的一些属性:
1 | // 拍卖的参数。时间单位是秒。 |
在这里,我们定义了一些合约内需要使用到的属性,这里address是Solidity中的特殊类型,记录了一个账户地址,这个账户地址可以是EOA账户,也可以是另一个合约账户。这里uint代表了一个无符号整型,Solidity中默认的大小为256位。mapping(address => uint)代表了一个哈希表,在这里可以看出是一个账户地址address到一个账户余额(这里是uint)的映射。Solidity中的哈希表不支持遍历,如果需要遍历哈希表,则需要额外记录一份包含所有键的数组。
1 | // 拍卖的事件 |
接着我们定义了一些时间,这些时间需要使用emit关键字触发。事件触发后会在区块链上记录下相应的日志,常见的事件有:记录代币或者以太币的转移、记录代币合约中批准某个地址可以代表另一个地址花费一定数量的代币、记录合约所有权的转移、记录存取款操作等,这些日志会用于生成Bloom Filter帮助外部软件快速查询发生过的事件。例如我们可以创建一个Web网站实时观察这个智能合约的竞价情况。
1 | // 出价函数 |
接下来我们定义了这个合约的主要内容,Solidity中使用function关键字声明函数。public是访问控制,表明这个函数谁可以调用,returns表明了这个函数返回什么参数。Payable意味着这个函数接受转账,这是Solidity中规定,所有接受转账的函数都需要声明payable。在编写智能合约的时候,我们可以使用require或者assert关键字审查条件,抛出错误:
assert(bool condition) |
如果条件不满足就抛出一个内部错误 |
|---|---|
require(bool condition) |
如果条件不满足就抛出一个用于输入或者外部组件组件引起的错误 |
revert() |
直接终止运行并且回滚状态变动 |
当其他账户想要调用它这个合约时,需要发起一笔对这个合约账户地址的交易,并且将需要调用的函数以及调用函数的参数(如果有)在data域中说明。在所有函数中,有一个特殊的函数,fallback()函数(在这个例子中没有)。这个函数与constructor()函数类似,名字是固定的并且没有参数,没有返回值。当其他账户调用合约时出现调用函数不存在,或者直接向该地址转账而不指明调用函数等错误调用时,就会默认调用这个函数。fallback()函数也可以标为payable,如果需要处理直接转账这类错误,则需要将fallback()函数标记为payable。如果没有标记为payable的fallback()函数被转账交易调用时,则会发生错误然后回滚。
EOA账户在调用合约的时候需要指明转账费用value,gas price单位gas的费用(即一单位gas换算为多少以太币),gas limit交易最多使用的gas费用。以太坊中单位gas的费用并非是一成不变的,其价格会随着网络的拥堵情况而上下浮动——当网络较为拥堵的时候,单位gas就比较贵,当网络不拥堵时,则比较便宜。除了EOA账户可以调用智能合约外,智能合约也可以调用智能合约。智能合约可以在代码中调用其他智能合约,有这样几种调用方式:
| 通过构造函数将地址转换成实例,在实例上调用函数 | 被调用函数报错,调用函数也会被回滚 |
|---|---|
address.call(要调用的函数的签名,函数的参数) |
执行出错不会引发回滚,而是返回一个false,如果执行成功,则返回true;可以通过.gas()和.value()调整提供的gas数量或者提供ETH的数量 |
address.delegatecall() |
与call相同,但是不能使用value;不切换上下文,使用当前合约的属性(存储,余额),主要用于使用存储在另外一个合约中的库lib代码 |
针对智能合约的攻击
智能合约一旦部署到区块链上,则再也不能更改。如果代码中有逻辑漏洞,会对合约的参与者造成重大损失。所以智能合约的代码在部署之前,都会先部署在测试网址以测试其安全性。同时,在编写Solidity代码时,开发人员需要遵守代码开发规范以避免常规漏洞的出现,例如在转账时,需要先判断条件,再改变条件,最后再和其他合约交互。这样的处理是为了防范重入攻击。
重入攻击
重入攻击是以太坊中最今典的案例,2016年黑客使用重入攻击从TheDAO合约中盗取了价值约1亿5000万美元的以太币,其总量为整个以太坊中以太币数量的二十分之一。由于其事件影响重大,以太坊不得不强制回滚链上数据,从而导致以太坊社区分裂。后来人称这个事件为TheDAO。以下是一段经典的重入攻击的代码:
1 | // 这是被攻击和合约的代码 |
1 | // 这是攻击者写的攻击代码 |
在这里,被攻击的合约内的withdraw()函数没有在转账之前就将攻击者账户清零,因此给了攻击者可乘之机。攻击者可以撰写一段恶意代码部署到网络中。这段恶意代码中,攻击者向被攻击合约存入了一定数量的以太币,又立马提取出来。提取时,攻击者的合约调用了被攻击合约的withdraw()函数,但是由于攻击者是使用智能合约调用被攻击智能合约的,因此被攻击合约会向攻击合约发起一笔交易。 在 Solidity 中,当一个合约接收到以太币时,如果没有显式定义一个接收以太币的函数(如 receive() 函数),或者如果没有匹配调用的函数签名(即没有匹配合约函数),那么合约会尝试执行它的回退函数(fallback 函数)。因此攻击者编写的合约中的fallback()函数会执行。而攻击者的fallback()函数中,再一次向被攻击合约发起了提取请求,于是被攻击合约会再一次向攻击合约转账。被攻击合约会一直向攻击合约转账,知道被攻击合约内的余额被清空。
为了预防这种攻击,我们在编写智能合约时可以遵守以下几点规则:
- 更新状态优先原则: 在转账或其他状态更改之前,应首先更新相关账户的状态。
- 使用最新的 Solidity 版本: Solidity 不断更新以修复安全漏洞,使用最新版本可以减少受到已知攻击方式的风险。
- 安全的资金转移模式: 使用
send()或transfer()函数进行资金转移,这些函数在转账失败时会抛出异常,可以避免部分重入攻击。
溢出攻击
Solidity中的整数一般都是256位的,如果超过256位,整数就会发生溢出。例如:
1 | // SPDX-License-Identifier: MIT |
例如在这段代码内,如果调用sub()函数给余额减去一个超过100的数字,余额就会溢出,变成一个很大的数字。因此,在Solidity中进行重要数据计算时,Solidity建议我们遵循以下几点建议:
- 安全的数学库:使用安全的数学库来进行整数操作,如
SafeMath库,可以有效避免整数溢出的风险。 - 适当的输入验证:在接收参数时进行适当的范围验证和检查,确保参数在合理范围内,避免意外的溢出情况。
- 使用适当的数据类型:根据业务逻辑选择合适的数据类型,避免不必要的数据类型溢出问题。
执行层
EVM

图中各个部分的内容对应解释如下:
- Contract:智能合约的字节码存储在这里,执行过程中会从中提取指令。
- PC(Program Counter):程序计数器,指示当前要执行的指令在字节码中的位置。
- OpCode:操作码,是从智能合约字节码中提取出来的具体指令。
- Gas:每执行一步操作都会消耗一定的Gas,Gas用于防止恶意代码无限循环消耗系统资源。
- operation:每个操作码对应的具体操作,由JumpTable(跳转表)中的具体实现来执行。
- JumpTable:存储所有可能的操作及其实现,共256种操作。
- Stack(堆栈):用于存储临时数据,最大深度为1024。
- Memory(内存):临时数据存储区,在合约执行期间可读写。
- StateDB(状态数据库):持久化存储智能合约的状态变化。
整个EVM的运行过程就是不断从合约字节码中获取指令,译码成操作码,然后根据操作码从JumpTable中找到对应的操作执行,并通过堆栈和内存来存储和获取临时数据,最后更新状态数据库中的合约状态,同时消耗相应的Gas来防止资源滥用。其具体执行步骤如下:
- 获取指令:EVM从程序计数器(PC)处获取当前要执行的指令。
- 译码:将获取的指令转换为机器可以理解的操作码(OpCode)。
- 执行:根据OpCode指示执行相应的操作。
- 获取指令返回值:执行完成后,从堆栈(Stack)或内存(Memory)中获取操作结果。
- 异常处理及指令跳转:如果在执行过程中遇到异常,会跳转到相应的异常处理逻辑;否则,继续执行下一条指令。
- 结果:最终,所有指令执行完毕,得到最终结果。
账本层
以太坊采用基于账户的交易模式,每个节点都要储存一份账本的副本。为了保持每个节点中账本状态一致,以太坊需要在账本状态上达成共识。直接将整个账本存储在区块内是不现实的,这个账本包含了全球所有账户的信息(其实是有余额账户的信息)。因此我们选择将所有账户构建成一个类似默克尔树的数据结构,而账本的状态使用储存在区块头内的一个哈希值(类似默克尔树根)表示。矿工可以通过这个字段判断自己本地的账户的副本是否域网络内的一致。我们把这个字段叫做默克尔帕特里夏树树根(Merkel Patricia Tree Root, MPT Root)。MPT是一个保存了所有交易哈希值的默克尔字典树,但是于比特币中默克尔树不同的是,这个默克尔字典树还储存了默克尔根到默克尔节点的路径,而且其路径是由该账户的地址决定的。
直接使用比特币中的默克尔树来代表矿工本地账本的副本将会面临种种问题:第一个问题就是不同人构建默克尔树顺序不一样,因此计算出来的默克尔根也不一样。第二个就是效率低下。如果我们对默克尔树进行排序,从而让每个人构建默克尔树的顺序一致,那么会导致查找和插入的效率十分低下。比特币中,一个默克尔树只包含几千个交易,而以太坊中储存的是全球所有账户的状态,有数千万个账户。这样构建出来的默克尔树十分庞大,如果我们希望插入一个新的账户,我们需要重新计算几千万次哈希。那我们可以直接使用哈希表存储账户的状态吗?直接使用哈希表存储账户的状态会让全节点无法提供默克尔证明,从而导致轻节点的消失。那我们可以把哈希表的内容组织成一个默克尔树吗?这样仍然会导致默克尔树更新和插入的效率低下——即便大多数账户是不变的,每次某一个账户的改变我们都要重新计算几千万次哈希。
在以太坊中,我们使用默克尔字典树来储存账户的状态。默克尔树是一种既查找、插入、和更新方便,又能提供默克尔证明的数据结构。以下是默克尔树的组成:
MPT的构成
MPT是三个数据结构的结合,分别是默克尔树,压缩字典树,字典树。字典树是一个存储键值对的树,在这个数据结构内,键是按照相同的前缀构成一个节点的,不同的后缀构成一个分叉而构成一颗树的。例如:apple, app, bat, ball, cat三个单词可以构成如下的字典树:
1 | root |
在上图中我们可以观察到,只有分叉的节点是有效的,大量竖直的节点造成了空间的浪费。因此,我们可以将相同前缀的节点压缩,构成一个压缩字典树,这样可以大大加速查询的效率。其图示如下:
1 | root |
以太坊采用Modified Merkel Patricia Tree储存账户的信息, MPT将默克尔树和压缩树结合,其保存了账户地址的哈希到账户内信息的映射。而以太坊在MPT之上,修改了一点,将节点分为了Branch Node,Extension Node,和Leaf Node三种节点构成Modified MPT。其图示如下:

在MPT中,我们将所有节点分为三个类别,分别是扩展节点(Extension Node)、分支节点(Branch Node)、和叶节点(Leaf Node)。扩展节点用于储存压缩字典树中共同前缀,在这里,地址哈希前缀相同的节点会被分到同一个分支。
- 一个扩展节点具体包括一个节点前缀 0,子节点共同前缀(例如上图中的a7),和下一个分支结点的哈希值。一个扩展结点一定对应了一个分支节点。
- 分支节点包含了0~F 16个16进制的数字(这里每一个数字我们叫做一个nibble,即4个bit,
个字节),代表不同子节点的前缀,和一个value字段,用于储存账户信息。分支节点内自己的哈希值由节点内的所有哈希值,使用RLP(Recursive Length Prefix)序列化后拼接起来(包括value字段,空的哈希值将会使用空值代替,例如“”空字符串或者None),再计算哈希得来。例如上图中的从上到下第一个蓝色的分支节点,其哈希值计算如下:
- 最后是叶节点,叶节点内保存了账户信息。例如账户余额,账户
nonce等信息。
我们对整个MPT的根节点做KECCAK256哈希运算,即KECCAK256(0 || a7 || 下一个节点哈希值),就能得到整个MPT的根哈希值,我们将这个根哈希值打包入区块头,其他人就能快速验证自己本地账本的状态和网络中其他矿工的账本状态是否一致。以下是go-ethereum中,区块头的代码:
1 | // Header represents a block header in the Ethereum blockchain. |
其中Root字段就是状态数的树根。以下是以太坊中区块的代码:
1 | type Block struct { |
以太坊的一个区块中,包含了多个交易,以及一个区块头和多个叔父区块头。其中,每笔交易内包含了转账金额,转账目标账户地址,以及附加的数据
以太坊中经常出现分叉,为了鼓励分叉尽快合并到最长链上,以太坊规定被主链包含的分叉区块也能得到奖励。被包含的区块将以叔父区块的形式记录在主链上。其具体规则会在GHOST协议中讲到。withdrawals字段则是根区块的奖励有关。以下是网络中传输的区块的信息:
1 | type extblock struct { |
状态树
在以太坊中,我们把网络中所有的账户结合起来,构建出一棵MPT树,我们把构建出来的树叫做状态树,意思是储存网络中账本状态的树。MPT树是以键值对的形式保存数据的——在状态树中,键(key)是账户的地址,值(value)是账户的状态,即账户的余额,交易次数,代码(合约账户),存储(合约账户)。通过MPT构建出来的状态树,不仅可以防伪,还能方便查找,更新和插入:
- 如果有人想要篡改账户余额,那么MPT的根哈希值就会改变。因此通过哈希值,我们能验证MPT中内容的真实性。
- 我们还能快速查找账户余额,例如我们想要查询上图中MPT内账户地址为a711355的余额,我们只需要找到a7节点,接着找字符1,接着1355,然后我们就能找到该账户对应的余额45 ETH。
- 如果需要更新账户内容或者插入新的账户,我们只需要重新计算修改账户的哈希值以及修改的账户之上节点的哈希值,就可以更新整颗状态树。并且我们不用担心构建顺序的不同,会导致生成不同的状态树。
在实际实现中,需要修改状态树的内容时,矿工会在原有的分支旁临时分叉出来一个新的分支来记录新的账户信息。因为以太坊中的出块速度很快,出块时间在15秒上下浮动。因此在链会经常分叉,矿工需要经常回滚账本的状态。账本的状态的改变跟智能合约的执行过程相关,我们很难根据通过交易内容和现在账本状态回推到上个账本状态,因此矿工需要时时保存账本的状态,回滚的时候只需要切换回原先的状态就好。
以太坊中,交易会引起状态树的改变,因此我们说以太坊是一个由交易驱动的状态机。但是为了达成一致,状态的转移必须是确定的,即相同的初始状态在经历相同的交易后,必须能得到相同的结果状态。所以在EVM中,我们的代码不能使用真随机数,也不能访问系统的信息(因为不同操作系统信息会带来差异)。
交易树和收据树
交易树和收据树分别对应者区块体中的交易和由交易产生的收据,者两棵树也是MPT树。其根哈希值也被记录在区块头中,分别对应了上面区块头代码中的TxHash和ReceiptHash字段。MPT树是以键值对的形式记录数据的。在交易树和收据树中,键(key)对应着交易(或者是收据)在区块中的序数,例如区块内的第几个交易,值对应着交易(或者是收据)内的具体内容。用MPT记录交易和收据,不仅可以让我们给轻节点提供默克尔证明(轻节点可以通过默克尔证明快速验证交易和收据),还可以方便我们快速查找交易和收据。从上面区块头的代码中可以看出,每个区块头都包含了一个Bloom字段。通过以太坊区块的头部当中Bloom字段,我们可以实现快速条件查询区块内包含的交易和收据中的内容。例如“查找所有区块中跟XXX账户相关的交易”。这种快速查找的机制是通过布隆过滤器(Bloom Filter)实现的,下面我将介绍以下布隆过滤器的具体原理。
共识层
GHOST协议

在GHOST协议中规定,如果矿工愿意在新挖出来的区块内包含叔块,每包含一个不同的叔块可以多拿挖矿奖励的。以太坊中的挖矿奖励是固定的,在2016年以前,每个新区块的奖励固定为5个以太币ETH,2016年以太坊大幅度降低了挖矿难度,为了公平起见,之后以太坊中每个新区块的奖励被降低为3个以太币。在这里,额外的以太币奖励是固定的。同时挖出叔块的矿工,可以得到常规挖矿奖
。为了控制网络内新币的发行,GHOST协议规定每个新区块最多只能包含两个叔块。
如果没有这样的奖励规则,当矿工发现自己所在的分链落后,会更倾向于在自己原本的链上继续挖矿,而不是切换到最长链上。因为如果一旦有机会赶上最长链,自己之前所有努力都能兑现,而一旦切换到最长链上,就要放弃所有可能的收益。这样往往会让两条链上的矿工在连续挖好几个区块后,才发现赶上另一条链的机会微乎其微。这样这个分链上的交易都要回滚,而且需要连续回滚好几个区块,这样不利于以太坊的生态。给与叔块挖矿者奖励可以鼓励挖矿者尽快回到主链,减少网络内的分叉。同时,如果没有GHOST规则,网络内的规则会鼓励矿池的发展。新区快在矿池内传播的更快,其发布的区块更有可能成为最长链,从而鼓励矿工加入矿池。集中化的大型矿池是以太坊团推不想看到的,给与叔块奖励可以极大的缓解这个问题。但这样的协议仍然会引起问题,相互竞争的矿池可以故意不包含对方的区块,这样自己的损失只有而对方的损失则有
。同时,如果同时有3个叔块出现,让有一个叔块不会被包含入主链,其中的矿工则还是倾向于在自己区块后挖矿,而不是加入主链。因此以太坊中改进了GHOST协议。
以太坊GHOST协议
在以太坊中,前六个区块的平级叔父区块都能获得奖励,并且奖励随着代数减少。例如当前上一个区块为父区块,与其平级同父的叔块能获得的奖励,再往前一级的叔块则只能得到
…一直到往前倒数第六级,其级叔块则只能得到
的奖励,再往前则没有奖励了。包含叔块的奖励仍是
,最多只能包含两个叔块。越来越少的奖励费鼓励叔块尽快和并入主网,而且这种形式也能防止矿工之间恶意竞争。

被包含的叔块虽然能拿到一定的区块奖励,但是叔块内交易的gas费矿工是得不到的。同时包含叔块的新区块是不验证叔块的交易的,只有叔块的头部被包含在新区块中。那我们能奖励叔块后面其他的分支区块吗?奖励叔块后面其他分支区块会降低分叉攻击成本,因此,只有每条分链的第一个区块能收到奖励。
以太坊中的PoW算法
加密货币在发展的过程中,挖矿设备逐渐专业化,集中化。在比特币网络内,矿工们最初用的挖矿设备为CPU,这也是中本聪构想中的挖矿设备。后来随着GPU的兴起,人们开始尝试使用GPU挖矿。一块GPU挖矿的效率是CPU的好几千倍。由于比特币中的挖矿收益是由其算力在网络中的占比决定的,随着越来越多的人开始迁移到GPU上挖矿,CPU挖矿的方式逐渐得不到任何收益。随着比特币市值的提高,又有人开始定制芯片挖矿,我们把这种定制芯片的挖矿机叫做ASIC,一个ASIC矿机的挖矿效率是GPU的好几千倍。现在比特币网络中,即便是使用最先进的显卡,也几乎得不到任何收益了。并且随着ASIC芯片的大规模应用,挖矿开始出现中心化的势头。ASIC芯片昂贵且异能用于挖矿,只有专业化的矿工才会购买,并且一次购买就是大量的购买然后组建矿场。可以预料到,未来随着越来越多的人大规模组建矿场,小规模的矿池会越来越得不到收益,最后整个网络中就会剩几个大矿池来负责挖矿。
对于这样的趋势社区中有人认为这样会强化比特币网络的安全性,因为一旦有人想恶意破坏,就需要购买大量昂贵的设备,且其在破坏后得不到任何收益。也有人认为这样违反中本聪最初的构想,中本聪最初在比特币白皮书中提到,比特币最理想的情况是“One CPU One Vote”,这样集中化的挖矿违反了最初“去中心化的理念”。以太坊中设计的挖矿算法就完全遵循去中心化的理念,以太坊中的算法是ASIC-Resistant的。即很难开发针对该挖矿算法的专业矿机。
ASIC-Resistant PoW 算法
ASIC专业矿机一般有以下特点,一是其在计算能力强,在计算特定算法时能耗很少,算力很高。二是其内存一般很小,因为给ASIC矿机大量配备内存是一件十分昂贵的事情。因此,针对ASIC矿机的缺点,我们可以设计出一种很难用ASIC矿机挖矿的算法,即对内存需求大的挖矿算法。这样对内存需求大的算法,我们一般叫做“Memory-Hard”算法。最早使用“Memory-Hard”算法的加密货币叫做LiteCoin,其使用的挖矿算法的名字叫做SCRYPT。其基本思想是,使用一个固定的很大的数组,在计算哈希算法时,需要按一定规则从数组内取值。数组可以是按一定规则生成的,例如数组内的每一个数字都是上一个数字的哈希值。这样我们就不用再数组内保存所有数字,我们只需要保存奇数位或者偶数位的数字(或者隔n个数字保存一个)。我们使用数组中的数字的时候就可以根据已有的数字计算出来。LiteCoin中使用的就是一个128Kb的数组,其中每一个数字都是上一个数字的哈希值,数组内的第一个数字是一个种子(seed)数字的哈希值。
但是这样设计出来的算法让轻节点验证成为难题。轻节点在验证的时候,也要计算出一个整个数组后,再计算哈希值。因此在设计数组时,往往不能设计的太大。在LiteCoin中,使用128Kb作为其数组大小,这是一个折中的设计。以太坊则采取了另一种算法,以太坊中分为一小一大两个数据集,小的数据集叫cache,大的数据集叫DAG;cache是由上述的方法用种子生成的,而DAG则是由cache生成的;轻节点只需要计算出cache就可以验证交易,而矿工则需要在内存内保存DAG来挖矿。这样既平衡了轻节点验证的需求,也抑制了专用矿机的发展。
以太坊采用的挖矿算法叫做ETHASH,其中小数据集cache有16MB,大的数据集DAG有1Gb大小,并且大小数据集都会随着时间增加,每隔30000个区块增加原始大小的,即小数据集增加128Kb,大数据集增加8Mb。这样的设计是为了应对越来越便宜的内存价格。且大小数据集的内容每隔30000个区块就改变一次,以防特定的固件产生。
DAG是通过cache数据集计算出来的,并且DAG中每个数据都想读独立,只依赖于cache中一小部分数据。这让轻节点在验证的时候不需要计算出整个DAG数据集,其只用实时计算出DAG中验证时需要使用的数据。而矿工在挖矿时,需要大量访问DAG数据集,因此矿工在内存中保存一份DAG数据集是更好的选择。
以太坊中,挖矿的难度和比特币一样也是动态调整的,其难度调整公式如下:
其中,是本区块难度,由基础部分和难度炸弹相加得到。
是整个网络中挖矿难度的下限,这意味着网络中不能有区块的挖矿难度币
小。
是难度炸弹,是以太坊创始人为了以太坊未来逐渐转为权益证明(PoS)而准备的。以太坊在发行初期,就计划未来使用权益证明而不是工作量证明。为了避免在转向权益证明的时候,以太坊中的矿工联合起来抵制权益证明,于是设计了这个难度炸弹。这个难度炸弹使得以太坊中区块在到达一定高度时,其挖矿难度会指数级增长。
的计算公式为
,其中
为区块的高度。在2016年,以太坊将实际的转向权益证明的时间向后推迟了三百万个区块,因此,在计算难度时所使用的区块高度比实际区块高度少三百万个区块,即
,其中为实际区块高度。
是挖矿难度的基础调整部分。
为父区块难度,每个难度都是在父区块难度上进行调整。
用于自适应调节区块难度,维持稳定的出块速度。其中
,是调整难度的单位。即调整难度时,是以父块难度的
为单位进行调整的。
,是调整难度的系数:如果这个系数是负的,意味着网络内难度需要往下调整;是正的话,意味着网络内难度因该往上调整。并且一次性最多往下调整99个难度单位——如果矿工能无限的向下调整难度的话,网络内的难度炸弹将不起作用。这个公式中
跟父区块中是否包含uncle区块有关,如果父区块包含uncle区块,则y为2,如果不包含,则y为1。这样做的主要原因是调整网络的货币发行速度。
则是用来评估出块时间快慢的,其中
是本区快的时间戳,
是父区快的时间戳,这两个时间的单位都是秒。例如假设现在父区块没有包含任何叔块,那么如果现在出块时间为:
- 【1,8】秒,则出块时间过短,我们需要将难度调高一个单位;
- 【9,17】秒,则出块时间刚刚好,区块难度不变;
- 【18,26】秒,则出块时间过慢,我们因该调低一个难度单位;
PoS 权益证明算法
PoW工作量证明因为耗电而一直收到收到诟病,因此有人提出一种基于集体投票的方法,确定产生链中的新区块。但是简单的投票容易受到女巫攻击,一个人可以伪造多个节点,发起多个投票。于是有人提出使用将投票的结果用其手上持有的加密货币比重加权统计结果,例如Alice,Bob,Charly分别持有3,4,5个以太币,那么Alice的投票结果就占最终结果的。这样统计投票结果就有很多好处,一是可以防御女巫攻击,如果一个人伪造多个节点,但是其在网络中所持有的虚拟货币占比总是固定的。二是如果有人想针对网络发起51%攻击,在攻击前他得大量买入该种类加密货币,这样会让该种类加密货币市值暴涨,这是网络内原先加密货币持有者乐于看到的。同时我们也按照投票的比例来分配收益,例如上例中Alice和Charly都投票给了一个区块,并且这个区块被确认到链上了,那么Alice和Charly都能获取一定收益。
这样也会导致许多问题,例如网络内拥有最多钱的人最容易挖到矿,从而导致富者越富,穷者越穷。因此我们规定,投票是按质押货币来操作的,并且质押过的币一段时间之内都不能再用于质押。同时这样的系统容易遭受Nothing at Stake攻击,矿工在遇到两个分叉的区块时,可以两边同时下注,即便一个区块被抛弃了,矿工异能从另外一个区块获得收益。这样容易造成链的分叉,也会降低分叉攻击的成本。因此我们规定如果有个给一个或者多个区块投票,则他因该收到惩罚。
以太坊所使用的权益证明PoS机制叫Casper:The Friendly Finality Gadget(FFG)。在这个机制中,时间被分为一个一个slot,每一个slot大概占15秒,理想情况下每个slot都要出一个区块。网络内的矿工需要对链中新产生的区块轮流投票,每个区块需要得到网络内的认可才能被附加到区块链上。每个人在选举的时候需要质押一定数量的保证金,如果接下来投票过程中发现有的节点给多个区块都投票了,则需要没收他的保证金。
网络层
网络协议分为两个部分:
- 发现:建立在用户数据报协议之上,并使新节点能够找到相应节点并连接
- DevP2P:建立在传输控制协议之上,并使节点能够交换信息
这两个部分并行作用, 发现协议将新的网络参与者输送到网络中,DevP2P则使它们进行交互。
发现
发现是在网络中寻找其他节点的过程。 该过程使用一小组引导节点(即地址硬编码为客户端的节点,以便它们能被立即找到,并将客户端连接至对等点)进行引导。 这些引导节点旨在将新节点引入一组对等点,这是它们唯一的目的。它们不参与普通的客户端任务,例如同步链,仅在第一次使用客户端时使用。
节点与引导节点交互所使用的协议是 Kademlia 的修改版,它使用分布式散列表共享节点列表。 每个节点都有一版此表格,其中包含连接到最近节点所需的信息。 这个“最近”不是指地理距离,而是由节点 ID 的相似性来界定的。 每个节点的表格都会定期刷新,作为一种安全特性。 例如,在 Discv5中,发现协议节点也可以发送显示客户端支持的子协议的聚合发现服务,以便对等点协调通信所用的协议。
发现过程从 PING-PONG 游戏开始。 一个成功的 PING-PONG 将新节点“连接”到一个启动节点。 提醒引导节点有新节点进入网络的初始消息为 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">PING</font>。 此 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">PING</font> 包括关于新节点、引导节点和过期时间戳的哈希信息。 引导节点接收到 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">PING</font> 返回 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">PONG</font>,其中包含 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">PING</font> 哈希值。 如果 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">PING</font> 和 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">PONG</font> 的哈希值相匹配,新节点和引导节点之间的连接就会得到验证,然后就认为它们已经“绑定”。
绑定之后,新节点即可向引导节点发送 <font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">FIND-NEIGHBOURS</font> 请求。 引导节点返回的数据包含一个新节点可以连接的节点列表。 如果这两个节点没有绑定,<font style="color:rgb(27, 27, 27);background-color:rgb(247, 247, 247);">FIND-NEIGHBOURS</font> 请求将失败,新节点将无法进入网络。
新节点从引导节点收到邻居节点列表后,就会开始与每个邻居节点交换 PING-PONG。 成功的 PING-PONG 将新节点与邻居节点绑定在一起,以实现消息交换。
1 | 启动客户端 --> 连接到 bootnode --> 绑定到 bootnode --> 寻找邻居--> 绑定到邻居。 |
执行客户端目前使用 Discv4发现协议,并且正在积极迁移到 Discv5协议。
ENR:以太坊节点记录
以太坊节点记录 (ENR) 是一个包含三个基本元素的对象:签名(根据某种商定的身份识别方案创建的记录内容的散列)、跟踪记录更改的序号和键:值对的任意列表。 这种格式不会过时,使新对等点之间身份识别信息的交换更加容易,并且是以太坊节点的首选网络地址格式。
发现为什么建立在UDP协议上?
UDP协议不支持任何错误检查、重新发送失败的数据包,或者动态地打开和关闭连接;相反,它只是将连续的信息流发送至目标,无论它们是否被对方成功接收。 这种最简化的功能会产生最少的连接开销,从而使这种连接非常迅速。 对于发现而言,如果某个节点只想让其它节点知道它的存在以便它与某个对等点建立正式的连接,UDP协议就已经足够了。 然而,对网络协议栈的其余部分来说,UDP协议就不那么合适了。 节点之间的信息交流相当复杂,因此需要一个功能更完善的协议来支持重新发送、错误检查等。 TCP协议带来更多功能所产生的额外连接开销是值得的。 因此,对等网络协议栈中的大多数协议在TCP协议之上运行。
DevP2P
DevP2P 本身就是以太坊为建立和维护对等网络而实施的一整套协议。 新节点进入网络后,它们的交互由 DevP2P(堆栈中的协议管控。 这些操作均基于传输控制协议,包括 RLPx 传输协议、线路协议和若干子协议。 RLPx是管理启动、验证和维护节点之间会话的协议。 使用 RLP(递归长前缀)的 RLPx 对消息进行编码。递归长度前缀是一种非常节省空间的编码方法,可将数据编码成最小结构,以便在节点之间发送。
两个节点之间的 RLPx 会话始于初始的加密握手。 这需要节点发送身份验证消息,然后等待对方进行验证。 成功验证后,对方会生成身份确认信息,并将信息返回初始节点。 这是一个密钥交换过程,使节点能够私下安全地进行沟通。 成功的加密握手会触发两个节点“在线”互相发送“hello”消息。 线路协议则通过成功地交换“hello”信息发起。
Hello 消息包含:
- 协议版本
- 客戶端 ID
- 端口
- 节点 ID
- 支持的子协议列表
成功交互需要这些信息,因为它们定义节点之间共享的能力并配置通信。 另外还有个子协议协调过程,届时会将每个节点支持的子协议列表进行对比,并能将两个节点共用的子协议用于会话。
除了“hello”消息之外,线路协议还可以发送一个“disconnect”消息,以警告对等点连接将被断开。 线路协议还包含定期发送的 PING 和 PONG 消息,以使会话保持开放。 因此,RLPx 和线路协议之间信息交换为节点之间的通信奠定了基础,并为根据特定子协议交换有用的信息提供了平台。