第3章
技术架构
以太坊是一个在点对点网络中能够运行智能合约,实现去中心化应用的区块链平台。其涉及的技术非常广泛和专业,有加密学相关的运算、校验和数据处理,P2P网络,区块链数据、智能合约和虚拟机,账户交易模型,共识与挖矿,去中心化的DApp应用等,每个方面的技术细节都足够展开一个篇章进行分析,但是以太坊的总体框架,应该仍旧归属在一个面向Web3.0的去中心化应用的分层区块链平台。
为什么提到Web3.0呢?Web3.0并不是一个完全创新的技术,而是一种思想和理念的创新。Web3.0在区块链中的体现将是一个在广域网络中,人人可以对等参与和协作的,不受中心化组织控制,自己管理自我数据和享受收益的网络世界。基于此,DApp的客户端提供业务入口,智能合约提供业务流控制,共识机制提供信任规则,区块链提供数据组织,点对点网络提供底层通信承载。图3-1描述了整体的全栈软件架构。
以太坊架构的顶层面向去中心化应用Web3.0的DApp,这也是以太坊最终面向社区、网络和区块链爱好者的价值蓝海。
3.1 概述
以太坊的技术架构可以从两个层面来进行分析。首先从全局角度来看,以太坊架构分为DApp应用和基础设施,如图3-1所示。
以太坊基础设施是矿工节点和同步节点组成的以太坊网络,矿工节点和同步节点的区别是矿工节点开启挖矿功能,而同步节点只同步区块数据,其他功能和数据都没有差别。以太坊应用大致可以分为以下几大类,如图 3-2 所示。
从图3-2中可以看出,以太坊应用最重要的特性就是接入以太坊的能力,为了能够让各种应用便捷使用以太坊提供的区块链和智能合约服务,以太坊提供了对开发者友好的各个语言版本的web3 包作为各种应用接入以太坊的基础。该 web3 服务提供了对以太坊接口 RPC 调用的一层封装,屏蔽了 HTTP 报文封装的格式,屏蔽了以太坊接口的技术细节,大大方便了各种 DApp 快速集成以太坊服务。
在命令行应用中,以太坊集成了很多有用的小工具,例如账户创建导入、ABI工具、RLP 工具,bootnode 管理、创世文件配置等,为开发者开发和部署以太坊提供了便利。
以太坊的几大主流应用包括浏览器、钱包、各行业相关的 DApp以及智能合约相关的开发工具和语言。其中,区块链浏览器是展示和查询区块链信息和用户信息的平台,像 etherscan 还集成了对智能合约的接口调用;钱包是以太坊的一个重要应用,是用户流量的主要入口,浏览器钱包的代表是 metaMask,可在线管理用户私钥并签名转账;移动钱包和桌面钱包一般为各个项目推出了为自己的代币服务的 APP,不仅可管理用户私钥,支持主流代币的转账,而且还可为自己的业务引流,是发展区块链业务的重要途径;DApp 是各个公司在区块链应用领域的积极探索,包括游戏、去中心化组织、去中心化交易所,等等;智能合约是以太坊最大的应用创新,相关的应用将在第5~7章中详细介绍。
以太坊的基础设施是由以太坊节点组成的 P2P 网络。其中以太坊节点的架构和传统中心化系统一样,遵循分层的功能设计。按照不同的功能目标,以太坊大概可以分解成如图 3-3 所示的结构。
3.2 Geth的架构与启动
Geth 是以太坊社区基于 Go 语言开发的以太坊节点程序,也称为以太坊客户端。与 Parity 公司开发的同名客户端 parity 一起,是目前社区和矿工使用最多的以太坊客户端,也是目前功能最完善、运行最稳定的客户端。在介绍 Geth 技术架构之前,我们将通过3.2.1节和3.2.2节来分析一下 Geth 的总体框架以及启动流程,让读者对以太坊的架构和运行流程有一个整体的把握。
3.2.1 Geth架构
Geth 中最重要的结构是 Node,你可以将其看作一个“容器”,所有以太坊客户端的功能都以服务的形式运行在该容器中。首先来看一下 Node 的结构,这里只列出了比较重要的与区块链功能相关的成员及注释:
type Node struct {
eventmux *event.TypeMux // 用于Node内各个服务之间的事件分发器
config *Config //节点的配置
accman *accounts.Manager //账号管理器
serverConfig p2p.Config //p2p服务的配置
server *p2p.Server // 当前运行的P2P网络层
serviceFuncs []ServiceConstructor //用于实例化各个注册的服务的构造器
services map[reflect.Type]Service // 当前运行的服务
rpcAPIs []rpc.API // 当前节点提供的API列表
inprocHandler *rpc.Server // In-process的RPC请求处理服务
ipcEndpoint string // IPC监听地址
ipcListener net.Listener // IPC-RPC的监听套接字
ipcHandler *rpc.Server // IPC-RPC的API请求处理服务
httpEndpoint string // HTTP监听地址
httpWhitelist []string // 可用于HTTP访问的RPC模块白名单
httpListener net.Listener // HTTP-RPC的监听套接字
httpHandler *rpc.Server // HTTP-RPC的API请求处理服务
wsEndpoint string // WS监听地址
wsListener net.Listener //可用于WS访问的RPC模块白名单
wsHandler *rpc.Server // WS-RPC的API请求处理服务
…
}
根据Node的结构定义,Node中的服务框架总体上可以划分为几大类,如图3-4所示。
该服务框架涵盖了3.1节介绍的以太坊节点框架的所有功能,其中Ethereum服务用于提供运行区块链业务的核心服务,这里将对Ethereum服务按照功能进行进一步划分,Ethereum包含的组件如图3-5所示。
其中, account.Manager 和 Node 的 accountManager 为同一个账户管理器,因为在 Node 上需要一个账户管理器监听账户操作的事件,所以需要在 Etheruem 实例化之前先初始化。EthAPIBackend 和 ethapi.PublicNetAPI 是 Etheruem 服务对外提供的所有区块链和P2P网络相关的 API的封装,单独提供这两个接口的实例是为了便于管理,不再需要 Ethereum的每个组件分别提供 API 与外界进行交互。
下文将基本按照上述框架来进行分别介绍。
3.2.2 Geth启动流程
3.2.1节介绍的 Node 实例是节点程序运行的基础,以太坊主程序和很多子命令的运行都需要首先启动 Node才能进一步运行所有服务。这里以以太坊全节点的启动为例,来看一下 Node 的启动过程,流程如下。
1) 首先创建一个Node实例,名称为stack。创建实例的同时,创建一个临时账户管理器,用于导入已存在的账户,以及配置4种RPC服务地址,RPC服务根据启动参数分别控制打开还是关闭,以及配置开放哪些RPC服务。
2) 注册Ethereum服务到stack。注意,这里并没有进行实例化,注册的是Ethereum服务的构造器函数,在后面再通过执行该构造器函数来实例化Ethereum。
3) 启动Node,即stack.Start()。在Node启动中完成下面的操作。
□配置P2P节点:生成节点私钥,配置节点名字,读取静态节点和信任节点列表。
□创建一个p2p.Server实例。
□启动Server中的服务,即创建一个Ethereum实例。Ethereum实例的初始化流程如下。
创建一个存储区块和链的数据库,底层数据库为 levelDB。
读取并保存创世块的配置并保存Node的临时账户管理器到本实例。
创建以太坊核心组件,具体如下。
TxPool:等待上链的交易缓存池。
Engine:共识引擎。
BlockChain:链和区块管理。
Miner:管理矿工挖矿。
AccountManager:账户管理。
ProtocolManager:peer消息收发。
4) 注册Ethereum中的Protocol到p2p.Server实例上。这里的Protocol是Protocol-Manager初始化时注册的eth/63和eth/62协议,用于对P2P协议层消息的收发进行处理。
5) 启动 p2p.Server,启动邻居节点发现,发现邻居并握手连接,刷新邻居列表。
6) 启动 Protocol,处理区块链业务消息,与邻居之间进行区块链相关数据的同步交互。
7) 启动 RPC 服务。主要工作为收集各个服务的 RPC 接口并注册 RPC 请求分发器,启动 http、websocket、ipc、in-process RPC 监听后端服务。
8) 监听 keystore 操作事件:创建钱包 WalletArrived,打开钱包 WalletOpened,删除钱包 WalletDropped。
9) 如果启动参数中使用了挖矿功能,则根据挖矿参数配置启动挖矿功能。
从上面的流程可以看出,p2p.Server 的初始化占据了很大的比重,实际上 p2p.Server 更像是节点的“发动机”,驱动节点的运行,进而驱动整个以太坊网络的运行。从 p2p.Server 的角度来看,Geth 的框架更像如图3-6所示的分层结构。
Node 的启动完成后,所有服务各司其职、相互配合、有条不紊地运行,具体 Node 中各个组件的运行流程和功能将在接下来的章节中详细介绍。
3.3 web3与RPC接口
前面介绍了用于以太坊应用快速集成以太坊的 web3 包。该 web3 包是对以太坊提供的JSON-RPC 接口的一次封装,如果开发者想直接封装HTTP请求调用 JSON-RPC 接口也是可以的。本节将介绍 web3 和 RPC 相关的内容。
3.3.1 以太坊中的JSON-RPC
JSON-RPC 是一种无状态且轻量级的基于 JSON 的跨语言远程调用协议。具有文本传输数据小,便于调试扩展的特点。
JSON-RPC 的请求数据以JSON格式编码到 HTTP Body 中,数据格式如下:
{
"jsonrpc" : 2.0,
"method" : "foo",
"params" : ["Hello World"],
"id" : 1
}
其中各参数说明如下。
□jsonrpc:定义 JSON-RPC 版本,一般指定为 2.0。
□method:所调用的方法名的字符串。
□params:调用方法所需要的结构化参数,若无参数则为null。
id:已建立客户端的唯一标识符。可以为数字或字符串,也可以为 null。
返回格式举例如下:
{
"id":64,
"jsonrpc": "2.0",
"result": "0xabcd…"
}
其中各参数说明如下。
□id:对应请求中的唯一标识符。
□jsonrpc:同请求字段。
□result:返回数据,根据各个接口自定义结构。
以太坊包含多个语言实现的版本,各个版本支持JSON-RPC的情况不尽相同,具体如图3-7所示。
在以太坊定义的 JSON-RPC 传输规范中,有两种数据类型需要通过十六进制字符串编码进行传输。一种是任意大小的数值,另一种是无格式的字节数组。
数值字符串格式为前缀“0x”+十六进制值。例如,“0x41”表示值65,“0x400”表示值1024。不允许出现“0x”和“0x0400”这种形式。
无格式的字节数组的字符串编码为前缀“0x”+每个字节的十六进制值。例如“0x41”表示字符“A”,“0x004200”表示字符串“0B0”、“0x”表示空字符串。
另外,在JSON-RPC请求的params中,最后一个参数可以携带默认值。默认参数具体包含以下几种。
□十六进制字符串格式的区块号。
□字符串“earliest”,表示创世区块或最早的区块。
□字符串“latest”,表示最新上链的区块。
□字符串“pending”,表示当前等待上链的交易。
下面我们以查询账户余额的RPC调用为例,看一下JSON-RPC的实际调用数据和返回结果。
请求数据:
curl -X POST --data
'{
"jsonrpc":"2.0",
"method":"eth_getBalance", //调用方法
"params":[
"0xc94770007dda54cF92009BFF0dE90c06F603a09f", //查询账户地址
"latest" //从最新上链的区块中查询
],
"id":1002
}'
结果:
{
"id":1002,
"jsonrpc": "2.0",
"result": "0x0234c8a3397aab58" // 十进制值为158972490234375000
}
3.3.2 以太坊RPC服务
以太坊提供多种RPC服务,除了提供基于HTTP/HTTPs的RPC服务端,还提供了WebSocket与本地进程间通信,以及进程内的RPC服务。由于各个语言版本的以太坊节点实现逻辑不尽相同,因此这里只给出一个RPC处理的逻辑流程,如图3-8所示。
服务端收到RPC请求后,解析请求数据,根据method方法分发到对应的处理函数。RPC服务端封装处理结果并返回给客户端。
Geth 版本以太坊节点提供的 RPC 服务可分为公开RPC接口和私有RPC接口两大类(API 列表未包含 Swarm、PoA 相关的API)。
□公开 RPC 接口。
公开RPC接口是指所有RPC客户端都可以调用执行的 API。具体如表3-1所示。
□私有RPC接口。
私有RPC接口默认不允许被HTTP和WS客户端调用执行,除非在Geth启动时指定外部用户可以访问的API。参数是--rpcapi“<可以访问的API模块,例如eth、admin、miner...>”。私有RPC接口具体如表3-2所示。
</p>
3.4 账户管理
以太坊账户是通过非对称加密算法生成的一对公钥私钥对,私钥用于签署交易,公钥用于生成账户地址,如果泄漏私钥则意味着账户的资产随时可能被转走;如果丢失私钥,则意味着账户资产被永久冻结。
因此,如何管理用户账户是关系资产安全的重要问题,以太坊除了支持硬件钱包,分成确定性钱包管理用户账户之外,还提供了一种软件管理的方式:keystore 文件。相比直接保存私钥的方式,增加了一层保护,黑客想盗取你的私钥,不仅需要盗取 keystore 文件,还要盗取用户密码。用户使用时,不需要输入私钥,直接输入密码就可以使用,这也增加了使用上的安全性。
3.4.1 keystore
Geth 在启动的时候,可以指定以太坊的运行路径,在该路径下保存该节点相关的所有数据,参数是“--datadir”。账户管理服务在该路径下会创建一个名为 keystore 的文件夹,该文件夹下面存放的是本节点的以太坊账户 keystore 文件。
keystore 文件可用来保存以太坊账户私钥的加密文件,该文件的数据是使用对称加密算法结合用户自己配置的密码加密后生成的。keystore 文件的内容举例如下:
{
"address":"bc2095fa058886b35a1aa004a8934a3f86370a7c",
"crypto":{
"cipher":"aes-128-ctr",
"ciphertext":"113148fc254c8678652bb…",
"cipherparams":{
"iv":"c01a51a0eeea24a8cd3e57baf8804fb3"
},
"kdf":"scrypt",
"kdfparams":{
"dklen":32,
"n":262144,
"p":1,
"r":8,
"salt":"30f25bd844bc08…"
},
"mac":"78a5a70d7ca9e…"
},
"id":"9844613b-4ea9-47b3-b68a-175c729aa000",
"version":3
}
下面介绍一下该文件的主要字段。
□cipher:指示 keystore 采用的对称加密AES算法的名称。
□cipherparams:上面 cipher 指定算法所需要的参数—初始化向量 IV。
□ciphertext:使用 cipher 指定算法对以太坊私钥进行加密后的输出结果。
□kdf:密钥生成函数名称,生成的秘钥用来加密 keystore 文件。
□kdfparams:上面kdf指定算法所需要的参数。
□Mac:用于验证密码时使用的数据。
有了上面的数据,下面我们开始介绍如何使用密码对私钥进行加解密。
首先是生成用于加解密的对称秘钥,使用 scrypt 算法来生成,输入参数为用户设置的密码,以及算法参数,使用的参数会保存在 keystore 的 kdfparams 字段中,如图 3-9 所示。
生成对称秘钥后,使用该秘钥对用户私钥进行加密,对称加密使用的算法是cipher算法,参数保存在keystore的cipherparams字段的IV中,加密结果保存在keystore的ciphertext字段中。
接下来是解密。解密需要用户输入密码并校验密码,校验方法是对加密私钥后的密文和通过密码生成对称秘钥进行SHA3-256计算,将计算结果与keystore中的mac值进行比较,结果相同则表示用户输入的密码是正确的,如图3-10所示。
使用上面生成对称秘钥对私钥密文 ciphertext 进行解密,如图 3-11 所示。
3.4.2 账户后端
Geth 通过钱包后端(Backend)的方式来管理钱包,钱包后端只定义了一组接口,实现钱包后端的实例只需要实现接口 Wallets () 和 Subscribe (),分别实现获取钱包列表和订阅钱包事件这两个功能即可。
Geth定义并注册了 3 种钱包后端,具体如下。
KeyStore 钱包
Ledger 硬件钱包
Trezor 硬件钱包
这里主要分析 KeyStore 钱包的功能,硬件钱包的管理由于篇幅有限,不再展开叙述。
KeyStore 钱包在初始化阶段,主要完成如下操作。
1)读取 KeyStore 文件夹下的所有账户数据,缓存到 accountCache。
2)启动一个 watcher,监听 datadir/KeyS 下的文件是否发生变化,如果发生变化,则会刷新 accountCache。
3)创建账户管理器 Manager。
4)注册 KeyStore 到 Manager。
5)Manager 启动事件 WalletEvent 的监听。
完成初始化后,账户管理框架如图 3-12 所示。
Manager 中的 Wallet 可以注册多个类型的钱包,图3-12中注册了 KeyStore 钱包,每个KeyStore钱包中都有多个账户,分别对应多个账户加密文件。每个账户可以设置解锁时间,在该时间内可以不输入密码进行直接转账等操作,因此使用离线签名的方式比较安全。
3.4.3 签名
钱包中的账户最重要的功能就是对指定的数据进行签名,最常用的是对交易进行签名。签名之前需要相对账户进行解锁,解锁过程如上面所述,并设置超时时间,超时后自动上锁,解锁成功的账户私钥记录在 unlocked 中。当需要签名时,首先从 unlocked 中检查该账户是否已解锁,如果已经解锁并且未超时,则使用该账户私钥对交易进行签名。
以太坊中生成签名数据的算法有两种,具体如下。
EIP155Signer。支持 EIP155(定义了以太坊 chainID,用于区分 ETH、ETC、测试网络),是 the DAO 攻击硬分叉之后使用的签名算法。
HomesteadSigner 和 FrontierSigner。不区分以太坊 ChainID。
区别是签名数据 R、S、V 中 V 的值不同。签名步骤具体如下。
1)对待签名数据计算Hash。
2)对 Hash 进行 ECDSA 签名。
3)使用私钥对数据进行签名,签名结果是 65 字节长度的数据,格式是 [R||S||V],R 和 S分别为 32 字节长度,V 值是 0 或者 1。
4)HomesteadSigner 和 FrontierSigner 对 V 值 +27,所以 V=27 或28。
5)EIP155Signer 对 V 值重新进行计算:对 V 值 +CHAIN_ID * 2 + 35。
6)返回最终结果 R、S、V。
3.5 节点网络管理
上文介绍p2p.Server时,节点网络服务根据数据不同阶段的处理将以太坊从下往上分为5层,具体如下。
业务层:处理以太坊核心业务。
协议层:分发处理各个协议层定义的消息。
传输层:实现节点连接的加解密校验,和对发送接收数据的RLP编解码。
会话层:根据邻居列表连接 peer,或者接受 peer 的连接。
节点发现层:实现节点发现和邻居列表的管理。
每一层各司其职,相互合作,完成节点之间区块链数据的交互,支撑整个以太坊网络的有序运转。
3.5.1 节点管理启动
首先,我们来看一下协议层的初始化。协议层是在实例化 Ethereum 结构时,创建了一个协议管理实例:NewProtocolManager(…)。
启动参数及说明如下。
ChainConfig:Geth启动参数和链配置。
SyncMode:区块下载器的同步模式—“fast”或者“full”。
networkID:P2P 网络的 ID,防止与其他 P2P 网络混淆。
event.TypeMux:事件分发器。
txPool:交易池。
Engine:共识引擎。
BlockChain:代表链的实例。
Chaindb:存储数据到数据库。
Whitelist:指定了一个区块号与区块 Hash 对应的列表,用来检查同步区块数据时下载的区块数据。
该实例保存参数中的 txPool、Engine、BlockChain,这几个是业务层的组件,方便协议层和业务层交互。
实例化一个downloader和fetcher。downloader和fetcher都是从peer获取区块数据,区别是downloader主动从远端获取区块和区块Hash,fetcher是根据远端通知过来的Hash去获取对应的区块。注意下载区块后验证区块头使用的函数是Engine提供的VerifyHeader。
创建多个通道,具体如下。
txsCh:当本节点有交易进入交易池时,通过该通道通知需要执行BroadcastTxs广播交易给peer。
newPeerCh:每个新的peer连接时,都需要在协议层为它创建一个peer结构,然后通过该通道通知进行数据同步;该peer结构除了指向真正代表一个邻居的peer结构之外,还保存了该邻居的区块头和总难度值,以及如下的队列和缓存。
knownTxs:已知的该peer已有的交易。
knownBlocks:已知的该peer已有的区块。
queueTxs:等待广播给该peer的交易队列。
queueProps:等待广播给该peer的区块队列。
queueAnns:等待宣布给该peer的区块队列。
noMorePeers:协议管理器停止工作后,通过该通道通知退出同步数据的工作。
txSyncCh:每个新的peer连接时,通知newPeerCh后,通过本通道通知发送本地交易池中所有的pending交易给指定的peer。
quitSync:停止协议管理器。
注册子协议,即eth63和eth62协议。在新的peer连接时,根据版本号、协议名字来适配对应的协议,在对应的协议中最终实现对P2P消息的协议层的数据分发和处理。
综上所述,ProtocolManager的总体框图如图3-13所示。
接下来是传输层和会话层的初始化。这两层的功能和初始化都包含在 p2p.Server 结构中,下面来看一下 p2p.Server 的启动过程,具体如下。
1)p2p.Server 在 Node 启动时实例化。
2)注册上面协议管理器中的子协议到 p2p.Server 实例上,以方便传输层将解码后的消息转给协议层处理。
3)启动 p2p.Server,具体如下。
配置传输层协议为 rlpx,负责对数据流进行 RLP 编解码。
配置会话层协议为 TCPDialer,负责与邻居建立 TCP 连接。
Server 中使用了很多 channel 用于消息传输,代码如下。
srv.quit:通知退出本服务。
srv.addpeer:收到新的连接时,p2p.Server通过该通道和邻居进行加密握手,并启动与该邻居的交互。
srv.delpeer:删除一个邻居。
srv.posthandshake:收到新的连接时,p2p.Server通过 addpeer通道加密握手后,通过该通道验证邻居连接的合法性。
srv.addstatic:通过 RPC 添加静态节点时,通过该通道完成添加。
srv.removestatic:通过 RPC 删除静态节点。
srv.peerOp:在获取邻居节点和邻居数量时,通过该通道获取。
srv.peerOpDone:获取邻居节点信息。
配置本地节点:生成节点公钥,握手数据,本地节点实例LocalNode。
启动TCP连接监听服务,默认最多可以有 50 个邻居连接。注意:节点发现和节点连接所使用的IP与端口号相同。
根据启动参数,初始化节点发现协议。
下面就来介绍节点发现协议。
3.5.2 节点发现协议启动
P2P节点发现采用类 Kademlia 协议。Kademlia 在 2002 年由美国纽约大学的PetarP.Manmounkov 和 DavidMazieres提出,是一种去中心化的 P2P 通信协议。Kademlia 节点之间使用 UDP进行通信,Kademlia 节点利用分布式散列表(DHT)技术存储数据,使用异或运算作为距离度量, BitTorrent、BitComet、Emule 等知名 P2P 软件中使用的就是该协议。列表的每一项为一个节点桶(bucket),每个桶中最多存放 16 个节点。列表的第 i 项代表距当前节点(本机)距离为 i+1 的网络节点集合。
节点间距离与节点的物理距离无关,仅仅是逻辑上的一种度量。计算方法具体如下。
1)用SHA3算法对节点ID(512位)生成一个256位Hash。
2)对待计算的两个节点的Hash进行XOR异或运算。
3) 输出的异或值中bit位为1的最高位的位数作为两个节点的距离。例如,异或值为 1000 1010 1110 0011,那么这两个节点的距离为 16。
如果Geth启动参数中没有禁止节点发现,那么在p2p.Server初始化的最后阶段将启动节点发现协议。节点发现的初始化工作入口是p2p.Server的setupDiscovery。当前Geth支持节点发现协议v4和v5版本,这里只分析v4版本的运行流程。启动初始化流程如下。
1)启动指定P2P连接的UDP端口监听(默认端口是30303)。
2)节点发现的配置包括:节点私钥、连接IP白名单、bootnode列表、未被处理的报文缓存队列unhandled、本地节点的信息localNode;其中bootnode列表是以太坊基金会在程序中内置的5个Go版本bootnode和1个C++版本bootnode,如图3-14所示。
3)创建监听UDP报文的Table;Table是节点发现中最重要的数据结构,如图3-15所示。
下面说明一下图3-15中的节点。
nursery 是在 Table 为空并且数据库中没有存储节点时的初始连接节点(上文中的 6 个节点),通过 bootnode 可以发现新的邻居。
Table.ips保存Table中的子网掩码长度是24,每个子网可接入至多10个IP的节点。
同样,bucket中的ips保存每个bucket中的子网掩码长度是24,每个子网可接入至多2个IP的节点。
Buckets是根据节点距离排序的K-桶。K-桶的数量是256/15=17个,每个K-桶存放16个入口。
每个K-桶的repalcesments存放的是新节点,当enties存满时,新节点暂存在repalcesments列表中,最多存放10个。
4)创建Table实例后将种子节点(本地数据库中配置的节点加上bootnode)加入到Table的相应的bucket中。
5)启动loop:启动定时器刷新随机数和刷新等待回复的队列,以及去主动发现可用的邻居,定时刷新K-桶数据的有效性,定时将K-桶数据存入数据库,定时刷新并删除过期节点。
6)监听UDP端口,处理收到的UDP报文。
定时刷新并发现节点。定时30分钟主动寻找邻居节点,以保证K-桶的状态是满的,除了定时刷新之外,还可以通过refreshReq进行“手动”刷新。寻找流程具体如下。
1)加载上面介绍的种子节点。
2)使用本地节点ID寻找自己的邻居中有没有新节点。
3)使用一个随机ID寻找邻居节点,连续寻找3次。注意:Kademlia协议指出K-桶刷新应在最近最少使用的K-桶中执行查找。Geth的实现版本没有遵守这一点,因为findnode目标是一个512位的值(不是散列大小),并且不容易生成落入所选K-桶的SHA3哈希。因此,我们使用随机目标ID来执行一些查找。
4)寻找邻居节点的过程如下。
从K-桶中查找距离该目标最近的16个非种子节点。
对每个节点发起节点查询:询问它们的邻居中距离目标ID最近的节点,将该节点更新到查询列表中,或者替换掉原16个节点中距离最远的节点。注意:可以并发3个routine同时查询,节点查询的方式是向节点发送findnode报文,详见下文介绍。
这样不断迭代询问,直到查到全网中最多16个距离目标ID最近的节点。
在进行节点查询的同时,将找到的节点添加到K-桶中。
定时刷新K-桶数据的有效性。这个间隔时间对于每次刷新来说都不相同,是一个0-10秒的随机值。验证方法是随机选择一个桶,取该桶的最后一个节点发送ping包:如果收到pong,则将该节点移到该桶的最前面;如果没有收到pong,那么从replacements中选择一个备选节点到enties,当然前提是replacements中有备选节点。
定时将K-桶数据存入数据库。周期为30秒。注意:如果某个邻居在K-桶中存在的时间超过5分钟,那么我们就认为这个邻居是一个稳定的节点。
定时刷新并删除过期节点。在每次检查最新 pong 报文收到时间的时候,都会确认一下过期数据刷新服务是否正在运行,如果没在运行,则启动定时器运行,周期是 1 小时刷新一次。对于 24 小时内没有 ping pong 过的邻居需要将其从数据库中删除。
监听邻居发过来的数据。UDP收到节点发现的数据,报文最大长度是1280字节,报文最短长度是mac(32字节)+sig(65字节)+1=98字节。如果收到的数据超过最大长度或短于最短长度则会被丢弃;如果报文处理失败,则将报文放入unhandled缓存队列(最多缓存100个报文)。
报文格式是[hash||sig||sigdata],参数解释具体如下。
hash:sig和sigdata的keccak256Hash。
sig:数据sigdata的签名。
sigdata:数据原文。
从签名中可以恢复出签名者的公钥。Sigdata的第一个字节是报文类型,有如下几种类型:pingPacket、pongPacket、findnodePacket、neighborsPacket,是2对请求–相应关系的报文,如图3-16所示。
然后执行各个报文的handle方法进行处理。
pingPacket报文处理
回复pong报文。
通过gotreplay通知处理ping的回复。
将node添加到Table中。
检查ping包距离上次收到pong的时间是否超过24小时,如果超过,则重新发送ping包给对端。
通知localNode更新状态。
记录最新的ping包收到的时间。
pongPacket报文处理
检查距离上次发送ping包的时间是否超过20秒。
通过gotreplay通知处理pong的回复。
通知localNode更新状态。
记录最新的pong包收到的时间。
findnodePacket报文处理
检查距离上次收到pong包是否超过24小时。
从K-桶中查找距离该新邻居最近的16个节点。
将最近的这些节点信息通过neighborsPacket报文告诉新邻居。
neighnorsPacket报文处理
检查距离上次发送的findnodePacket包时间是否超过20秒。
通过gotreplay通知处理neighborsPacket的回复。
3.5.3 节点创建和连接
本地节点向可用的邻居主动发起连接,或者邻居节点请求连接时,p2p.Server 为每个连接创建一个 conn 实例,该实例定义了消息的加解密和编解码处理,连接过程如下。
1)每个连接首先进行2次握手,握手过程进行加密验证。
2)生成 enode 地址。
3)通过 channel: posthandshake 通知 p2p.Server 进行本次连接有效性检查,过程如下。
如果不是静态节点或信任节点,并且超过最大 peer 连接数,则拒绝本次连接。
如果不是信任节点,但是是邻居主动请求连接的节点,则在超过邻居请求连接数量时,拒绝本次连接。
如果是重复连接或者是自身节点环回连接,则直接拒绝。
4)进行transport层的握手,发送一个RLP编码的消息,消息code是handshake-Msg(0x00)。
5)通过channel:addpeer进行添加Peer的操作,即创建一个Peer实例。
6)运行该实例,即Peer.run(),具体如下。
启动接收peer消息的监听服务。
启动定时发送ping保活消息。
运行Peer中注册的Protocol,即Protocol.Run(...)。
在协议层进行握手。向peer发送本地节点信息(协议版本号、网络ID、链总难度值、当前区块头、创世块数据),消息code为StatusMsg。
等待peer回复对端的StatusMsg,检查与本节点是否匹配。
通过newPeerCh通道通知protocolManager检查peer的区块高度,如果peer的最新高度比当前节点高,那么同步最新的区块到本地,然后向给本节点所有的peer广播最新的区块Hash。如图3-17所示。
通过txsyncCh 通道向peer通知protocolManager 同步本地交易池的交易。如图 3-18 所示。
启动ProtocolManager的消息接收服务,处理收到的消息。完成节点创建和连接后,p2p.Server的整体框图如图3-19所示。
3.5.4 消息处理
在Peer的消息接收服务(readLoop)中,收到Peer发送过来的消息时,根据消息code进行分发。消息code的定义如下。
handshakeMsg = 0x00,握手消息。首次连接后,协议层发送的握手报文,对端收到该消息后无须处理。
discMsg = 0x01,断开连接消息。该消息是本次连接中协议层发送的最后一个消息。
pingMsg = 0x02,该消息是协议层定时发送的 ping 保活消息。
pongMsg = 0x03,对端收到ping保活消息后,立即发送该 pong 消息进行实时回复。
其他 code 值是协议层自定义的消息 code。偏移值为 16,即从 0x10 开始编号,例如 TxMsg 最终 code 编码值是 0x12。协议层协议版本是 eth/63 消息的 code 预留数量是 17 个,版本 eth/62 的协议 code 预留数量是 8 个,具体如下。
StatusMsg = 0x00,该消息只有在协议层握手阶段才会发送和处理,握手完成后将不再使用。
NewBlockHashesMsg = 0x01,收到新的区块Hash,如果本地节点没有该block,那么通过fetcher服务获取该区块。
TxMsg = 0x02,收到 peer 广播过来的交易,通过交易池的 AddRemotes 接口加入交易池中。
GetBlockHeadersMsg = 0x03,请求区块头的消息,以最大的消息长度上限将区块头数据发送给 peer。消息最大长度为 2MBytesh。
BlockHeadersMsg = 0x04,通过GetBlockHeadersMsg请求后,收到的区块头数据。
GetBlockBodiesMsg = 0x05,请求区块体的消息,最多一次获取 128 个区块体数据。
BlockBodiesMsg = 0x06,收到区块体。
NewBlockMsg = 0x07收到新的区块,加入到 fetcher 的缓存中。如果收到的block 区块号比本地最新区块要高出1个高度以上,那么尝试从peer下载那些中间缺失的区块。
以下的Msg 属于 eth/63。
GetNodeDataMsg = 0x0d,请求获取指定状态根 Hash 的所有状态数据。
NodeDataMsg = 0x0e,收到状态数据。
GetReceiptsMsg = 0x0f,请求交易凭证数据。
ReceiptsMsg = 0x10,收到交易凭证数据。
3.6 交易管理
交易管理是指对刚发送到节点但还未被打包上链的交易和从其他节点广播过来的交易进行缓存管理,其涉及交易池的管理,交易的发送和广播,交易缓存策略。
在实例化 Ethereum 时,创建交易池的实例:NewTxPool(…)。
3.6.1 交易池
交易池专门用来缓存未上链的交易。交易池将交易分别缓存在2种类型的队列中,具体如下。
pending队列。这里缓存的交易是满足可以被打包条件的交易,只是由于网络广播、gas价格等因素,不能立即被矿工选中打包,因此先缓存在pending队列中。
queued队列。这里缓存的交易是已提交到节点上,但不能被立即执行,需要先缓存在queued队列中的交易。例如,某个交易的nonce字段填了一个明显大于当前nonce值的nonce。注意:在queued中的交易不会被节点广播。
交易池可以配置的参数具体如下。
Lifetime:在queue队列中的最长等待时间。默认为3小时。
PriceLimit:最低的交易GasPrice。该值限制进入该交易池的交易的最小GasPrice。默认值为1。
PriceBump:替换相同Nonce的交易的GasPrice百分比,默认值为10%。如果新的交易想替换相同nonce的pending中的交易,则需要设置的GasPrice至少为相同nonce交易的GasPrice*(1+10%)。
AccountSlots:每个账户的pending槽位的最小值,即每个账户最多的pending交易数量。默认值为16。
AccountQueue:每个账户的queueing槽位的最小值。默认值为64。
GlobalSlots:全局pending队列的最大值。默认值为4096。
GlobalQueue:全局queueing的最大值。默认值为1024。
Geth中交易池的结构的主要字段具体如下:
type TxPool struct {
config TxPoolConfig //交易池的所有配置
chain blockChain //当前链的数据结构
gasPrice *big.Int //最低的GasPrice限制
currentMaxGas uint64 //当前最新区块的gaslimit
chainHeadCh chan ChainHeadEvent // 订阅了区块头更新的消息
currentState *state.StateDB // 保存当前区块头中的所有状态
pendingState *state.ManagedState //用来跟踪虚拟nonce的pending状态
currentMaxGas *big.Int //交易上限的GasLimit
journal *txJournal // 本地交易的日志记录,用于重启恢复
pending map[common.Address]*txList //当前可以处理的所有交易
queue map[common.Address]*txList //所有"未来"的交易
all map[common.Hash]*types.Transaction //交易池中的所有交易
priced *txPricedList //按照价格排序的交易
}
触发交易池更新的操作主要列举如下。
订阅区块头的更新事件,接口为 TxPool.reset。区块链网络运行后,不断创建和更新区块链的最新区块头数据(例如,区块被上链,分叉区块被抛弃),都需要对交易池中的交易进行更新,收到区块头的更新事件后,主要处理的工作具体如下。
产生最新的区块,将该区块中打包的交易从交易池中删除。
若由于分叉等情况导致区块被丢弃,则需要将丢弃的区块中的交易重新放入交易池,等待下一次被选中并重新打包。
更新与当前最新区块相关的 currentState 和 pendingState。
pending和queued队列中的交易因为某些交易的加入或删除,需要重新排序和调整。
本地客户端连接节点发送离线交易,或者使用节点存储的账户创建交易。接口为TxPool.AddLocal和TxPool.AddLocals(批量添加)。
收到邻居节点广播的交易。接口为 TxPool.AddFRemote 和 TxPool.AddRemotes(批量添加)。
3.6.2 交易提交
签名后的交易通过 RPC 接口发送到节点后,并不会立即被放入交易池,而是需要先检查和校验合法性,一方面要防止恶意的交易影响节点运行,另一方面可以尽早发现存在交易明显失败的情况,防止交易发送者受到不必要的资金损失:一些交易执行失败的场景例如智能合约函数调用,都需要付出相应地 Gas 费用。交易检查不仅需要检查交易的合法性,还与交易池的配置有关。
交易检查和校验的内容具体如下。
1)检查交易Hash是否重复。如果在交易池中已经存在相同的交易Hash,则表明该交易是重复交易,应该被丢弃。
2)验证交易的长度是否异常,交易最大长度为32KB。
3)如果交易带有转账金额,则验证交易发送者的余额是否足够。
4)验证交易执行最终需要花费的Gas是否超过当前区块的GasLimit;注意区块链的GasLimit的上限可以通过Geth启动参数调整。
5)验证交易签名。从签名中恢复签名者地址,检查其与交易的From字段是否一致。
6)验证交易中设置的GasLimit是否足够支持交易的成功执行,如果不够的话,则返回相应的错误提示。注意:合约创建和函数调用需要额外的、更多的Gas花费。
7)验证Nonce值。Nonce值不允许小于当前Nonce值,小于的话需要丢弃该交易。如果Nonce值和pending中的交易Nonce值相同,则判断新的交易的GasPrice是否高于pending的交易GasPrice,如果高于pending的交易,则替换旧的pending的交易,旧交易被丢弃,否则丢弃新的交易。
8)如果交易数据中填的Nonce不是当前Nonce+1,也不是pending交易中连续Nonce的最大值+1,那么该交易的Nonce值过大,需要被加入到queued队列中。
9)最后,交易池的容量是有限的(可以在启动参数中指定交易池相关的配置)。如果当前交易池已满,发送者继续向该节点发送交易时,如果该交易的Gas花费比当前交易池中其他交易最低的Gas花费要高(其他发送者的pending交易),那么就丢弃那条Gas花费最低的交易,将本交易加入pending队列。
10)上述对于交易的检查会在交易广播到每个节点时都执行一次,由于存在网络延时即各个节点交易池的配置不同,因此可能存在同一个交易在不同的节点上有不同的处理结果,即数据无法保证完全同步,不过不用担心,这种情况不会影响交易的安全性。
11)如果交易发送者发送的交易在pending或queued队列中等待,而且首先被打包上链的交易是执行转账的交易,导致账户余额无法继续支付下面排队的交易Gas费用,那么剩下的pending交易将被删除。
3.6.3 交易广播
交易被提交到交易池后,还需要广播给相连的邻居,流程如下。
1)节点收到用户提交的交易,或者收到邻居广播过来的交易,或者queued队列中满足条件可以被执行的交易,当这些交易验证有效性后,进入交易池的pending队列,发布NewxsEvent事件。
2)协议管理器protocolManager订阅该事件,事件中应携带需要广播的交易数据。
3)protocolManager会将消息发送给还没保存该交易的所有邻居。
4)通过之前的介绍,我们知道protocolManager维护了邻居信息的集合,其中每个邻居对应的peer实例都有一个knownTxs缓存,该缓存存储当前节点知道的邻居所具有的交易,通过查询该缓存可以得知该邻居是否已经存在待广播的交易。
5)将待广播交易加入到peer实例的缓存queueTxs;注意最多可以添加128个交易。
6)更新knownTxs,表示邻居已经知道该交易。
7)对交易进行RLP编码后,发送给邻居,消息code为txMsg。
3.7 链和区块管理
在实例化Ethereum时,创建Blockchain的实例:NewBlockChain(...)。Blockchain管理所有已经完成处理的区块相关数据,包括持久化到数据库,维护链结构等。
3.7.1 区块的结构
区块分为区块头和区块体,结构定义如图3-20所示。
注意:Block 中的总难度值 Td (所有历史区块难度值之和)存放在区块头外部,因为随着该区块插入到规范链(CanonicalChain)或切换到分叉链,该值都可能发生改变,但 Header 数据无须更新,所以区块 Hash 也不用更新。
Header 中的字段说明具体如下。
ParentHash:该区块的父块 Hash,每个区块只能有一个父块,并且区块号连续,但允许某个时候存在多个子块(分叉)。
UncleHash:该区块所有叔块的区块头进行 RLP 编码后的 Hash 值。
Coinbase:打包这个区块的矿工地址。
Root:状态树的根 Hash。
TxHash:交易树的根 Hash。
ReceiptHash:交易凭证树的根 Hash。
Bloom:用于查询本区块中智能合约的事件。
Number:本区块的区块号,区块号是连续编号,创世块的区块号是0。
Difficulty:当前区块的难度。
GasLimit,GasUsed:区块可包含的 Gas 上限,以及实际交易累加的 Gas 花费。
Time:当前区块被打包的 Unix 时间。
Extra:区块存储额外数据的字段,可以是矿工自定义的,也能被用来扩展区块数据,例如,PoA共识中该字段可用来存储矿工信息。
mixDigest,nonce:用来验证 PoW 中挖矿的结果。
交易结构中最重要的是 txdata 结构,各字段说明如下。
AccountNonce:交易发送者发送的交易序列,是一个基于 0 的连续递增不可重复的整数,用于防止重播消息。
GasLimit:本次交易可用的 Gas 上限。
Price:本次交易的 Gas 价格(单位是 wei)。
Amount:交易转账 ETH 数量(单位是 wei)。
Recipient:交易的接收者地址。
PayLoad:交易携带的数据,是可变长度的二进制数据负载。
V,R,S:发送者对交易的签名。
3.7.2 区块数据验证
区块数据验证的功能由验证器 Validator 实现,是 Blockchain 的一个成员变量。验证器提供了验证区块体和验证状态的功能。
1.验证区块体
主要工作是确认指定区块的叔块,验证区块头中的交易和叔块根Hash。注意:验证区块体之前已有其他流程验证过区块头。验证过程具体如下。
1)根据区块 Hash 和区块号查询 Blockchain 数据库中是否已经存储有该区块和该区块的状态根,如果已存在,则表明无须验证,返回 ErrKnownBlock。
2)通过 Engine 的接口 VerifyUncles 验证给定块的叔块是否符合给定引擎的共识规则。
3)计算 uncle 的 Hash,检查是否与区块头中存储的叔块 Hash 一致。计算方法为:对当前区块的所有 uncle 区块头进行 RLP 编码并生成 Hash。
4)计算交易树根 Hash,检查是否与区块头中存储的根 Hash 一致。
5)检查该区块的父块是否存储在 Blockchain 数据库中:如果不在,同时也不在缓存中,则表示该区块具有一个未知的祖先,返回 ErrUnknownAncestor,如果在缓存中,则表明该区块具有已知的祖先,但是状态是无效的,返回 ErrPrunedAncestor。
2.验证区块状态
主要工作是验证状态转换后发生的各种更改,例如使用的Gas数量,交易凭证根Hash和状态根Hash。状态转换功能由链管理的处理器processer提供,该processer在父区块的状态基础上,执行状态转换,生成Gas数量,交易凭证根Hash和状态根Hash。
3.7.3 区块“上链”
区块数据按照来源不同可以分为几种:本地历史区块、邻居历史区块、邻居间同步最新区块、本地矿工挖到的最新区块。按照类型可以分为已经在canonicalChain上的区块、分叉链上的区块、查过当前高度的“未来区块”、处于最新高度的区块。无论如何划分,链管理服务均为这几类区块提供了统一的处理流程,如图3-21所示。
1.外部区块数据导入
无论是历史区块,还是邻居的最新区块都可以看作为外部区块数据,下面对外部数据的来源再进行一下细分。
通过 Geth 子命令:import 批量导入区块数据。
通过 RPC 接口 admin_importChain 批量导入区块数据。
协议管理器的 downloader 和 fetcher 从邻居中获取区块数据。
BlockChain 中对外部区块数据导入提供了统一的入口:InsertChain。对于导入的数据要么进入不涉及任何选择的典范链(canonical chain),要么成为一个分叉区块。
InsertChain 的执行过程具体如下。
1)对待插入的区块进行一次健全检查:区块号是否连续;区块的父 Hash 是否指向前一个区块。注意:要求区块连续的这个条件可以简化插入流程,比如验证第一个区块时,发现它是已存储的区块,那么对于接下来的区块可以采取相同的措施,而不必挨个处理了。
2)恢复所有区块中所有交易的签名者。
3)通过 Engine 接口 VerifyHeaders 验证所有区块头。
4)创建一个插入迭代器:insertIterator,迭代器包含了一个验证器 Validator。
5)先取出第一个待导入的区块,通过验证器验证区块体数据是否有效,验证过程如下。
若待导入区块的父块不在 canonicalChain 中(验证返回的错误码是ErrPruned-Ancestor),则表明当前块处于分叉链上,执行 insertSideChain 操作。
若待导入的区块是已存储的块(验证返回的错误码是 ErrKnownBlock),则跳过所有比当前节点上最新高度要低的区块。
若待导入的区块比当前节点上的区块高度超前了,或者它的父块是超前块,则认为它是 FutureBlock,并对所有接下来的区块执行 addFutureBlock 操作。
FutureBlock 的概念是超过当前最新高度但是率先到达的区块,例如,当前节点正在同步历史区块,但是邻居已经将最新区块广播过来,需要暂时先将这些区块缓存起来;对于这种情况,BlockChain 在实例化后会启动一个5秒定时服务 procFutureBlocks,用于处理满足条件的未来的区块数据,这个条件就是区块时间戳不超过当前时间 30秒以上,以及最多只处理 256 个未来区块。
因为 insertChain 要求插入的区块列表是连续的,而 procFutureBlocks 中的区块不一定连续,因此需要挨个插入。
6)对于第一个区块的验证,如果没有返回错误,或者已经处理完了所有返回错误的区块(insertSideChain 或 addFutureBlock),那么就可以开始处理“正常”的区块插入了。
首先,检查区块Hash是不是分主链的 Hash (一般是硬分叉后端区块 Hash),如果是的话,则直接中断导入工作。目前有两个:05bef30ef572270f654746da22639a7a0c97dd97a7050b9e252391996aaeb689和 7d05d08cbc596a2e5e4f13b80a743e53e09221b5323c3a61946b20873e58583f
获取父块的状态数据。
根据父块状态数据和本区块的交易,执行状态转换,得到 Gas 数量、交易凭证根 Hash 和状态根 Hash,然后通过验证器对结果进行验证,详细过程见3.7.2节。
向数据库写入区块数据和状态,接口为 WtiteBlockWithState。
上述流程中触发 insertSideChain 执行的条件一般为导入了一条“很老”的分叉链区块数据,可能是由于本地节点长时间未连接到主链,独自挖矿导致的,也可能是恶意的分叉。
如果是长时间未同步,那么同步这个分叉区块数据之后,需要切换最新的链为该分叉链,并抛弃本地的、无效的区块。
如果是恶意攻击,例如状态影子攻击(shadow-state attack),那么立即中断区块数据的导入。注意:状态影子攻击是指攻击者构造了一串从历史高度开始的假区块,总高度超过当前高度,其首个区块的状态根和对应高度的 canonicalChain 中的状态根一样,但其他区块数据为造假数据。如果插入区块时未对剩余区块做检查,也没有对其父块做校验,那么本地链数据将被替换为攻击者构造区块数据。
2.本地挖矿产生的区块
本地的区块肯定是基于当前节点上的最新高度来生成的,所以可以直接通过WtiteBlockWithState 接口存储区块。
下面介绍区块数据存储的核心流程 WtiteBlockWithState 接口。此时通过验证的区块可以存储到数据库中,但是该区块能否写入 canonicalChain 还需要做进一步判断,甚至可能是该区块写入分叉链之后,导致分叉链的高度超过 canonicalChain 的高度,那么这条分叉链将会升级成为新的 canonicalChain。WtiteBlockWithState 的处理流程具体如下。
1)获取待插入区块的总难度和其父块的总难度。总难度值是所有历史区块的难度值之和,在分叉之间用来判断哪一条分叉的高度更高。
2)获取当前 canonicalChain 最新高度的总难度。
3)将待插入区块的总难度存储到数据库。
4)并将待插入区块的区块头和区块体分别存储到数据库。
5)提交最新状态到数据库,最新状态即为执行完待提交区块后的状态数据。
6)将待插入区块中的交易凭证存储到数据库。
7)开始处理分叉:通过比较待插入区块的总难度和 canonicalChain 最新高度的总难度判断是否需要重组链。
如果总难度值相等,那么需要判断区块号的大小:如果待插入的区块号等于canonical-Chain 最新高度,那就随机选择任一条分叉作为最新的 canonicalChain,如果是大于,则表示待插入区块所在的分叉比当前节点上的 canonicalChain 要更符合成为canonicalChain 的条件,应该成为新的 canonicalChain,本地链成为分叉链;如果小于,则表示本地 canonicalChain 更符合条件,应丢弃待插入区块。
如果待插入区块的总难度值更大,那么待插入区块需要插入到 canonicalChain。
如果待插入区块的总难度值较小,则表示待插入区块处于另一个分叉,应丢弃该区块。
8)检查待插入区块是否已经缓存在 futureBlocks 中,如果存在的话,则需要删除,表示已处理过该区块。
上面处理分叉的过程中提到了如果待插入区块使得其所在的分叉链更符合成为canonicalChain 的条件,那么需要重组新的 canonicalChain。这里的重组由 reorg 函数实现。总体步骤具体如下。
找到分叉链和原 canonicalChain 共同的祖先,即分叉点。
将分叉链上的区块重新插入 canonicalChain。
将原 canonicalChain 中存在但是没有被打包进新的原 canonicalChain 的交易,从数据库中删除,最终重新进入交易池等待被打包。
3.8 共识管理
以太坊公链采用与比特币类似的 PoW(Proof of Work)共识算法,同样借用了“挖矿”的概念,只有矿工可以“挖到”区块,挖到区块的矿工可以获得一定的奖励。以太坊上的所有账户都可以成为矿工,都可以参与“挖矿”,所以大家会想尽办法获得出块权,为了保证尽量公平竞争,PoW提供了一种密码学“难题”,使得矿工无法走捷径,必须暴力计算,计算能力越强的矿工解答难题的概率就越大(这个机制的一个负面影响就是矿工抱团形成矿池,成为垄断巨头,这个不在本章的讨论范围之内),这就是以太坊中的密码经济学。本节主要介绍与共识相关的组件。共识管理的整体框图如图3-22所示。
以太坊将挖矿和生成区块的组件需要提供的功能封装成统一的 Engine 接口,实现 Engine 接口的共识算法都可以为以太坊提供共识机制,目前官方支持的共识算法有 PoW(Ethash)和 PoA(Clique)。Worker 组件管理挖矿的启停,Miner 负责与外部组件的交互。
3.8.1 Engine
Engine 是一个共识引擎的接口定义。每个 Miner 都会配置一个 Engine。在实例化Ethereum 时,创建 Engine 的实例,目前有两种共识 Engine:Ethash 和 Clique,分别代表 PoW 共识算法和 PoA 共识算法,根据创世文件配置选择一种共识算法并实例化。两种共识算法的具体实现请参见第4章,这里以 Ethash 为例,介绍 Engine 为其他组件提供的接口 及其工作流程。
Ethash 内部框架如图3-23所示。
在实例化 Ethash 时,会创建一个支持远程挖矿的服务:remote,在 Geth 启动参数中通过miner.notify 指定了支持远程挖矿的外部矿工 URL 。远程挖矿的接口说明如下。
GetWork:为外部矿工提供当前挖矿的信息,包括当前正在计算的区块头 Hash、DAG 种子、目标边界值(2 ^ 256 / difficulty)、区块号。
SubmitWork:外部矿工提交挖矿结果,返回挖矿是否被接受的结果。如果验证通过,则将封装好的区块通过 results 通道返回给 Worker。
SubmithashRate:外部矿工上报算力,便于汇总统计。
GetHashRate:查询本地 CPU 和远端矿工的算力。
Ethash 实现的 Engine 的 API 及其主要流程如下。
Seal:挖矿封装 Block。
首先通过workCh 通道通知远程外部矿工进行挖矿,前提是配置了远程矿工 URL。
开启本地 CPU 挖矿,启动的数量和 CPU 内核数一致。本地挖矿的算法详见第4章。
等待矿工找到合适的 Nonce 值,封装好区块并返回给 Worker。
SealHash:为区块生成全局唯一的 Hash。生成算法具体如下。
对区块头中的如下数据按照顺序进行RLP编码:header.ParentHash、 header.UncleHash、header.Coinbase、header.Root、header.TxHash、header.ReceiptHash、header.Bloom、header.Difficulty、header.Number、header.GasLimit、header.GasUsed、header.Time、header.Extra。
对编码结果通过 Keccak256 算法计算 Hash,该Hash值就是该区块的 Hash。
VerifySeal
根据区块头的 Hash,和区块头中的 nonce,通过 PoW 算法计算digest 和result,比较 degist 和 header.digest 是否一致,判断 result 是否大于(2^256/区块头中的难度值),如果都满足则表示矿工执行的 PoW 挖矿证明是正确的。
Finailize
收集区块和叔块的奖励。
将状态转换后的所有修改数据提交到底层数据库,并生成状态根 Hash 保存到区块头。
区块中所有字段都已生成,封装一个完整的 Block 结构。
Prepare
根据父块信息和区块时间计算区块难度,计算公式如下:
v1 = 2 if len(parent.uncles) else 1
v2 = (timestamp - parent.timestamp) // 9
v3 = max(v1-v2,-99)
pdiff = parent_diff + (parent_diff / 2048 * v3)
diff = pdiff+ 2^(periodCount - 2)
3.8.2 Worker
每个 Miner 都会配置一个 Worker。Worker 即矿工,在实例化 Miner 之前,首先实例化一个Worker:newWorker,实例化完成后会立即尝试启动挖矿。Worker 和外界通过通道和事件订阅来进行交互。
订阅的事件有3种,具体如下。
NewTxsEvent:交易池收到新交易。通过 txsSub 通道接收。
对于处于停止挖矿的节点收到交易(可能是多笔交易,也可能是收到重复交易)后,按照价格和 Nonce 进行排序,过滤重复交易,对有效的交易在待打包的区块上执行状态转换。对于正在挖矿的节点收到交易后,如果共识引擎是 Clique(PoA共识)并且共识周期配置为 0,则表示该 Clique 是配置为交易驱动出块的模式,那么每次收到交易时就需要进行挖矿并生成最新区块。
ChainHeadEvent:收到新的区块。通过 chainSideSub 通道接收。
与启动挖矿的流程一样,清除本地已打包上链的交易,然后基于最新的区块头重新挖矿。重新挖矿的工作是通过通知 newWorkCh 通道完成的,详见下面的介绍。
ChainSideEvent:收到叔块。通过 chainHeadSub 通道接收。
保存并更新叔块缓存,然后决定基于哪一条分叉继续进行挖矿。如果叔块高度超过正在挖矿区块的高度,那么需要本地矿工决定基于哪一条分叉继续挖矿,协议规定需要选择最长链继续挖矿。
除了订阅其他组件的事件来驱动矿工挖矿之外,本地也定义了各种通道完成不同的挖矿需求。主要通道具体如下。
startCh:启动挖矿。
与上面介绍的收到 ChainHeadEvent 事件一样,清空交易池中无效的交易后,基于最新的区块头通过 newWorkCh 通道通知开始挖矿。
谁会通过该通道来触发启动挖矿呢?
Miner 启动时会通过该通道通知 Worker “起来干活了”!
创建 Worker 实例后,首次通过该通道尝试开始挖矿。
newWorkCh:基于父块挖矿并组装区块。
挖矿的工作由矿工Worker来完成,这个矿工实际上是个“包工头”,除了自己使用CPU挖矿之外,还找来之前登记的远程矿工来完成最终的挖矿工作。Ethash采用的挖矿算法是Ethash算法,该算法的具体实现见第4章,这里不再赘述。
矿工一直等待直到一次挖矿完成(成功计算出符合要求的Hash值),然后“交差”:组装区块并提交Work。组装区块commitNewWork的流程如下。
1)以当前节点保存的链上最新块作为父块,构建区块头:父块Hash作为Parenthash,区块高度加1。
2)执行engine.Prepare接口实现的流程,对于不同的共识协议,该接口实现的功能有所差异。
3)更新Work实例的数据(每个矿工持有一个当前工作的Work实例):主要工作是保存父块的所有状态数据,更新新的区块头,更新当前区块的所有祖先区块和叔伯区块。
4)从本地交易池中取出pending的交易,根据价格和Nonce规则过滤出可以打包进当前区块的交易列表。
5)执行每一笔交易,即状态转换:
首先设置当前交易的Hash、索引、当前区块Hash到状态数据库,相应的实现函数为env.state.Prepare;
接着就可以执行状态转换函数:env.commtTransaction;
6)重新计算新区块的叔块。
7)执行engine.Finalize接口实现的流程,对于不同的共识协议,该接口实现的功能有所差异。
8)将该块的父块从未确认区块中移除。
taskCh:对挖到的区块进行“密封”(sealing)。“密封”可通过共识引擎完成,接口为engine.Seal。
resultCh:处理“密封”结果。
1)将区块、交易凭证、最新状态保存到数据库chain.WriteBlockWithState中,并返回该区块是否为分叉的块。
2)该区块通过protocolMamager广播给邻居。minedBroadcastLoop连续调用两次BroadcastBlock,两次调用仅仅只是一个Bool型参数@propagate不一样:当该参数为true时,会将整个新区块依次发给相邻区块中的一小部分;而当其为false时,仅仅将新区块的Hash值和Number发送给所有相邻列表。
3)如果是分叉块,则广播ChainSideEvent事件,否则广播ChainEvent和Chain-Head-Event事件。
4)在广播事件的同时,将区块中所有交易产生的Logs也通知给事件订阅者。将区块放入未确认区块缓存。
unconfirmedBlocks存储未确认的区块。本地挖到区块后需要经过7个块高度的确认才能被认为是成功出块。
3.8.3 Miner
创建 Engine 和 Worker 实例后,创建 Miner 实例。Miner 结构如下:
创建 Miner 实例后,可以选择是否启动挖矿,选择取决于下面两个条件。
1)Geth启动参数是否配置了使能挖矿功能。如果未使能,则不需要启动,等待手动开启。
2)如果使能挖矿,那么当前节点存储的区块链数据是否处于最新的高度。如果不是最新的高度,则需要先从邻居节点同步最新区块到本地,然后再开启挖矿。
第2个条件的解决办法是通过监听区块下载器的广播事件。创建Miner实例后,立即启动一次性的事件订阅服务,订阅区块下载器的“开始”(StartEvent)、“完成”(DoneEvent)或“失败”(FailedEvent)事件。注意:每次启动只处理一次DoneEvent或FailedEvent事件,处理该事件后立即退出订阅循环,这样做的目的是为了防止恶意注入区块的DOS攻击。如果Miner不停监听下载器收到的区块的事件,挖矿服务将一直占用节点资源,最终将导致节点不可用。
事件订阅服务订阅到StartEvent事件后,立即停止当前正在进行的挖矿工作,等待下载器完成区块下载,并设置canStart=0,shouldStart=1。
事件订阅服务订阅到DoneEvent和FailedEvent事件后:如果shouldStart=1,则开始挖矿。具体的挖矿工作由Worker来完成,因此只需要通知Worker启动挖矿即可,通知通道为Worker的startCh。
3.8.4 共识激励
吸引矿工不断投入资源进行挖矿的驱动力是以太坊的激励机制,即付出劳动可以获得一定的奖励。奖励包括以下两个部分。
打包区块中的交易费用。
获得出块权的固定奖励。
交易费用将在第6章中详细介绍,这里不再赘述,本节主要介绍固定奖励。以太坊采用 PoW共识,出块权是矿工根据计算算力和“运气”争取得到的。PoW 机制以及较短的出块时间(相比较于比特币)决定了以太坊在运行过程中更容易出现分叉的可能,当然这是协议允许的,以太坊通过 GHOST 协议来解决分叉问题,但对于同步较慢的矿工来说这一点不太公平。与比特币不同的是,以太坊考虑这种正常的分叉也是矿工付出了一定的劳动成本,也可以获得一定数量的奖励,当然没有上主链的区块奖励多。以太坊使用叔块(Uncle Block)的概念定义处于分叉中但最后未成为主链区块的区块,叔块即父块的兄弟,如果接下来的区块基于最新高度生成,那么叔块中的交易最终会被解散重新进入交易池,如图3-24所示。
叔块在全部挖掘出来的区块中所占的比例称为叔块率,目前以太坊叔块率在 6.3%左右。
以太坊的固定奖励包括普通区块的奖励和叔块奖励,具体如下。
普通区块的固定奖励。在君士坦丁堡硬分叉升级之后固定奖励为2 ETH。如果普通区块包含了叔块,则每包含一个叔块就可以得到固定奖励的 1/32。
叔块的固定奖励。叔块的奖励计算有些复杂,公式为:
叔块奖励 =(叔块高度 + 8 - 包含叔块的区块的高度)* 普通区块奖励 / 8
3.9 数据库
以太坊中处理过的的区块链相关的数据最终都会持久化到数据库中,以太坊采用的底层数据库是 LevelDB,LevelDB 是由 Google 开发的基于 key-value 的非关系型数据库存储系统,特别适用于写多读少的场景。
在 Geth 启动流程这一节的介绍中提到过,创建 Ethereum 实例时,创建一个数据库用于存储链相关的数据,该数据库实例为 LDBDatabase,对 levelDB 的基本操作进行了一层封装,提供数据库的操作接口,数据库的名字为“chaindata”。Ethereum 中的组件也引用了该实例作为数据库,如图 3-25 所示。
3.9.1 rawdb
rawdb实际上是Geth提供的一个Golang包,提供了直接通过LDBDatabase读写区块链相关数据的接口。按照存储的数据类型不同,接口可以分为3类,具体见表3-3至表3-5。
3.9.2 stateDB
除了存储3.9.1节的链相关的数据,更重要的是存储每个账户的状态数据,因为以太坊支持合约账户,每个合约账户会存储大量的状态数据,以太坊中使用 MPT 树(Merkle Patricia trie)来组织所有状态数据,再将组织后的数据存储到底层数据库 LevelDB。MPT 树的机制在其他章节中有介绍,这里不再赘述,本节通过一个实例来展示以太坊对 MPT 树的实现。
首先来看一下以太坊MPT树键值路径的表示方法。我们知道MPT树是在Patriciatrie树的基础上结合Merkle提供数据真实性快速验证。一般情况下使用trie树,数据的路径path为26个字母组成的字符串,例如“coin”。可以使用长度为26的数组来索引字母组成的路径。而在以太坊中存在大量的Hash值,形如“0x8c4c3dfe045770a8bc...,”如果将其作为路径,则不适合用字母表来索引,需要寻找另一种方式来索引Hash路径,以太坊使用十六进制值来做索引,索引数组长度也变为16(0~0xf)。以太坊将Hash值转换为十六进制字符串“8c4c3dfe045770a8bc...”,为了统一算法,也将一般字符串转换为字节数组,例如“coin”为[64,6f,67,65],再转换为十六进制字符串“646f6765”,然后取出每一个元素,其数值范围就是0~15,作为索引,这样就统一了Hash和字符串索引:[8,c,4,c...],[6,4,6,f...]。
通过拆分字节的方式统一了键值路径的表示方法是一种可行的思路,但是这种方式在代码实现时会浪费存储空间:之前一个字节(8c),要使用2个字节的空间来分开存储:[8,c]。以太坊为了节约空间,使用了一种压缩字节的表示方法,将2个字节拼成一个字节存储,只是计算的时候分别取出来使用,但这样又引入了一个新的问题,由于Patricia是压缩trie树,压缩后的索引长度存在奇数的情况,无法组成完整的字节,例如[64,6f,67,65],压缩后可能是[6,46f,6765],这样一个字节就无法放下 46f 这样的索引了。为了解决这个问题,以太坊对索引增加一个字节的前缀,该前缀前 4 位指示当前索引是奇数长度还是偶数长度,实际上也是指示扩展节点还是叶子节点,具体表示方式如表3-6所示。
如果是奇数长度,那么该前缀的后 4 位用来存储多出来的索引值,例如压缩后的索引[6,46f,6765] 添加前缀之后,可能表示为 [16,146f,206765] ,其中 16 表示奇数长度扩展节点索引,146f 表示奇数长度扩展节点索引,206765 表示偶数长度叶子节点索引。
了解了以太坊键值索引的设计之后,下面来看一个实际的例子,为了方便展示,定义 4 组 key值为字符串的键值对:
接下来看一下生成 MPT 树后的结果,如下:
这就是一棵完整的按照Patricia树组织的压缩前缀树,数据以Hash为Key存储在levelDB上,Hash正如merkle树一样,自下而上生成,最终得到一个rootHash,任何数据的篡改,都能通过比对根Hash来发现。
如果要查询“whois”这个key对应的值是多少,则从rootHash开始,取出数据为[<17,76>,hashA],匹配索引为776,因为是奇数并且是扩展节点,所以加前缀1,找到HashA,接下来的索引是8,在HashA对应的值中找到第8个值,为HashB,继续从数据库中取出HashB的数据,发现还是个扩展节点,匹配索引6f,取出HashD的数据,接下来取出索引6,发现是HashE,从数据库中取出HashE的数据,发现还是个扩展节点,但是索引正好是973,表明path路径已搜索完毕,value值存放在HashF的数据的最后一个成员中,即“potato”。
相信通过上面的例子,读者应该对以太坊 MPT 的组织和存储有了清晰的认识。接下来继续看 stateDB 结构的组织。
为了方便上层服务操作状态数据,这里通过提供一个 stateDB 对象,为每个账户分配一个stateObject实例,简化了上层服务对状态数据 MPT 树的操作,如图 3-26 所示。
stateDB存储与MPT树相关的所有数据,StateDB为区块中每一个需要修改状态的账户建立一个stateObject对象,trie.Database是一个存储已读取和待写入底层数据库trie数据的缓存,state.cachingDB实现了state.Database接口,是关于trie和合约代码的操作接口,屏蔽了trie树的操作细节,定义如下:
type Database interface {
// 打开账户trie树
OpenTrie(root common.Hash) (Trie, error)
// 打开一个账户的存储trie树
OpenStorageTrie(addrHash, root common.Hash) (Trie, error)
//复制给定的trie树
CopyTrie(Trie) Trie
// 获取合约字节码
ContractCode(addrHash, codeHash common.Hash) ([]byte, error)
// 获取合约大小
ContractCodeSize(addrHash, codeHash common.Hash) (int, error)
// 获取底层存储trie数据的数据库
TrieDB() *trie.Database
}
由此可见,为了管理和操作状态数据,stateDB 只维护了需要修改的账户和状态数据集合,通过分级“缓存”的方式抽象各个操作,使得每一层次都“可插拔”,操作过程总体如下。
1)数据的更新在封装区块和验证区块时,生成一个StateDB实例。
2)如果有状态被更新,即执行SetState(),那么stateObjectDirty会记录下该账户的更新数据,所有的更新数据均存储在stateOBject的ditryStorage中。
3)当执行IntermediateRoot()时,所有ditryStorage均被更新到trie中。
4)在执行CommitTo()时,trie中的数据被持久化到底层数据库levelDB中。
StateDB结构维护的MPT树,并不像levelDB一样作为单个数据结构实例存储在数据库中,而是只需要在使用时根据状态根Hash从数据库中提取相关的数据组成该结构即可,处理完成后,再将更新的树节点数据存储到数据库中。这样做的好处显而易见,可以大大节省存储空间,例如,当前块只包含一笔对其中某个账户的转账交易,那么,根据当前块的交易对父块状态进行状态转换时,其中只修改了历史区块状态树中一个账户下的一个状态变量,修改后重新计算当前块的状态根Hash,保存到当前块的区块头中,再对这个账户状态修改相关的分支节点、叶子节点并存储到数据库中。在子块进行状态转换需要提取当前块的状态树时,当前块的状态根Hash提取到所有相关的树节点数据,其中包含当前块更新过的树节点数据,以及未更新过的父块树节点数据。总之,这是一种类似Github代码提交的增量修改的机制,每次状态转换只存储修改的部分,然后与未修改部分生成新的状态树根Hash。
状态转换需要支持回滚操作,因为在智能合约的执行过程中可能存在各种原因的中断执行,因此需要对已经修改的状态进行回滚。stateDB 通过 journal、revision 来管理状态修改历史和回滚。journal 结构如下:
type journal struct {
entries []journalEntry //修改日志
dirties map[common.Address]int // 被修改的账户和修改次数
}
其中 journalEntry 是一个接口定义,定义了revert和dirtied,记录修改数据和修改的地址。由于数据类型较多,因此不同的类型对该接口的实现也不同,具体包括如下修改内容。
□stateObject修改
□智能合约suicide操作
□账户余额修改
□Nonce修改
□合约字节码更新
□状态变量修改
□退款
□添加日志
□添加preimage
revision,顾名思义,用来描述一个“版本”,作为回退的依据。每次有数据进行修改时,系统都记录一个版本,如图3-27所示。
需要进行回退操作时,只需要指定revision版本号,取出对应的journalIndex,将对应的所有历史修改都恢复到历史值即可。
3.10 Ethereum对外操作接口
在实例化Ethereum时,创建一个EthAPIBackend的实例,对Ethereum之外的服务提供统一的友好的数据访问和操作。API可分为以下几大类别。
1.通用的Ethereum服务API
2.链相关的API
3.交易池相关的API
3.11 本章小结
本章以Go版本以太坊Geth为基础,详细分析了以太坊的技术架构,从P2P节点发现到RPC接口,先从交易签名到区块上链,从钱包管理到共识引擎,以太坊向我们展示了一个实现“世界计算机”的构想。相信随着以太坊2.0的到来,以太坊能够从架构层面解决更多的理论问题,真正成为价值互联网的基础设施。