以太坊生活的所思所感,探讨去中心化思想,推动区块链的发展。
发布于 3 个月前 作者 stvenyin 54223 次浏览 来自 以太坊
  • 以太坊数据结构与存储分析

  • SEP 21th, 2018

  • 一.概述

  • 在以太坊中,数据的存储大致分为三个部分,分别是:状态数据、区块链和底层数据

  • image.png

  • 二.区块部分

  • 区块结构:

  • 区块链是以太坊的核心之一,所有交易以及结构都存于一个个区块中,接下来我们看看以太坊中的区块结构是怎样的。

  • 以太坊中所有结构定义基本都可以在 core/types 中找到,区块结构定义在 block.go

  • image.png

  • 以 writeHeader 为例:

  • image.png

  • 在存储 header 时,内容部分的 key 为:前缀+num(uint64 big endian)+hash;value 是区块

  • 头的 rlp 编码。其余的模块存储方式类似: image.png

  • 各种前缀也都定义在 database_util.go 文件中,值得注意的是,在此文件中还有用于只存储区块头的一套函数,应该是为提高以太坊的灵活性。 image.png

  • 三.交易部分

  • 交易部分的内容比较多,已写在专门的一个文档,请查看上一篇内容。

  • 四.Merkle-PatriciaTrie

  • Merkle-PatriciaTrie 简介:

  • Ethereum 使用的 Merkle-PatriciaTrie(MPT)结构,源自于 Trie 结构,又分别继承了

  • PatriciaTrie 和 MerkleTree 的优点,并基于内部数据的特性,设计了全新的节点体系和插入载入机制。

  • Trie,又称为字典树或者前缀树(prefix tree),属于查找树的一种。它与平衡二叉树的主要不同点包括:每个节点数据所携带的 key 不会存储在 Trie 的节点中,而是通过该节点在整个树形结构里位置来体现;同一个父节点的子节点,共享该父节点的 key 作为它们各自 key 的前缀,因此根节点 key 为空;待存储的数据只存于叶子节点中,非叶子节点帮助形成叶子节点 key 的前缀。下图来自 wiki-Trie,展示了一个简单的 Trie 结构。 image.png

  • PatriciaTrie,又被称为 RadixTree 或紧凑前缀树(compact prefix tree),是一种空间使用率经过优化的 Trie。与 Trie 不同的是,PatriciaTrie 里如果存在一个父节点只有一个子节点, 那么这个父节点将与其子节点合并。这样可以缩短 Trie 中不必要的深度,大大加快搜索节点速度。

  • MerkleTree,也叫哈希树(hash tree),是密码学的一个概念,注意理论上它不一定是 Trie。在哈希树中,叶子节点的标签是它所关联数据块的哈希值,而非叶子节点的标签是它的所有子节点的标签拼接而成字符串的哈希值。哈希树的优势在于,它能够对大量的数据内容迅速作出高效且安全的验证。假设一个 hash tree 中有 n 个叶子节点,如果想要验证其中一个叶子节点是否正确-即该节点数据属于源数据集合并且数据本身完整,所需哈希计算的时间复杂度是是 O(log(n)),相比之下 hash list 大约需要时间复杂度 O(n)的哈希计算,hash

  • tree 的表现无疑是优秀的。

image.png

  • 上图来自 wiki-MerkleTree,展示了一个简单的二叉哈希树。四个有效数据块 L1-L4, 分别被关联到一个叶子节点上。Hash0-0 和 Hash0-1 分别等于数据块 L1 和 L2 的哈希值, 而 Hash0 则等于 Hash0-0 和 Hash0-1 二者拼接成的新字符串的哈希值,依次类推,根节点的标签 topHash 等于 Hash0 和 Hash1 二者拼接成的新字符串的哈希值。比特币在存储交易时就用的此种数据结构。
  • 实现部分
  • MPT 的相关代码在 tire 文件夹中。
  • Node.go 中定义了四种类型的节点,分别是:

image.png

  • 其中,nodeFlag 是用于在创建/修改节点是存放缓存数据的。

image.png

  • fullNode 是一个可以携带多个子节点的父(枝)节点。它有一个容量为 17 的 node 数组成员变量 Children,数组中前 16 个空位分别对应 16 进制(hex)下的 0-9a-f,这样对于每个子节点,根据其 key 值 16 进制形式下的第一位的值,就可挂载到 Children 数组的某个位置,fullNode 本身不再需要额外 key 变量;Children 数组的第 17 位,留给该 fullNode 的数据部分。fullNode 明显继承了原生 trie 的特点,而每个父节点最多拥有 16 个分支也包含了基于总体效率的考量。
  • shortNode 是一个仅有一个子节点的父(枝)节点。它的成员变量 Val 指向一个子节点,而成员 Key 是一个任意长度的字符串(字节数组[]byte)。显然 shortNode 的设计体现了PatriciaTrie 的特点,通过合并只有一个子节点的父节点和其子节点来缩短 trie 的深度,结果就是有些节点会有长度更长的 key。
  • valueNode 充当 MPT 的叶子节点。它其实是字节数组[]byte 的一个别名,不带子节点。在使用中,valueNode 就是所携带数据部分的 RLP 哈希值,长度 32byte,数据的 RLP 编码值作为 valueNode 的匹配项存储在数据库里。
  • 这三种类型覆盖了一个普通 Trie(也许是 PatriciaTrie)的所有节点需求。任何一个[k,v]类型数据被插入一个 MPT 时,会以 k 字符串为路径沿着 root 向下延伸,在此次插入结束时首先成为一个 valueNode,k 会以自顶点 root 起到到该节点止的 key path 形式存在。但之后随着其他节点的不断插入和删除,根据 MPT 结构的要求,原有节点可能会变化成其他node 实现类型,同时 MPT 中也会不断裂变或者合并出新的(父)节点。比如:
  • 假设一个 shortNode S 已经有一个子节点 A,现在要新插入一个子节点 B,那么会有两种可能,要么新节点 B 沿着 A 的路径继续向下,这样 S 的子节点会被更新;要么 S 的Key 分裂成两段,前一段分配给 S 作为新的 Key,同时裂变出一个新的 fullNode 作为 S 的子节点,以同时容纳 B,以及需要更新的 A。
  • 如果一个 fullNode 原本只有两个子节点,现在要删除其中一个子节点,那么这个fullNode 就会退化为 shortNode,同时保留的子节点如果是 shortNode,还可以跟它再合并。
  • 如果一个 shortNode 的子节点是叶子节点同时又被删除了,那么这个 shortNode 就会退化成一个 valueNode,成为一个叶子节点。诸如此类的情形还有很多,提前设想过这些案例,才能正确实现 MPT 的插入/删除/查找等操作。当然,所有查找树(search tree)结构的操作,免不了用到递归。
  • hashNode 跟 valueNode 一样,也是字符数组[]byte 的一个别名,同样存放 32byte 的哈希值,也没有子节点。不同的是,hashNode 是 fullNode 或者 shortNode 对象的 RLP 哈希值,所以它跟 valueNode 在使用上有着莫大的不同。
  • 在 MPT 中,hashNode 几乎不会单独存在(有时遍历遇到一个 hashNode 往往因为原本的 node 被折叠了),而是以 nodeFlag 结构体的成员(nodeFlag.hash)的形式,被 fullNode 和shortNode 间接持有。一旦 fullNode 或 shortNode 的成员变量(包括子结构)发生任何变化,它们的 hashNode 就一定需要更新。所以在 trie.Trie 结构体的 insert(),delete()等函数实现中,可以看到除了新创建的 fullNode、shortNode,那些子结构有所改变的 fullNode、shortNode 的 nodeFlag 成员也会被重设,hashNode 会被清空。在下次 trie.Hash()调用时,整个 MPT 自底向上的遍历过程中,所有清空的 hashNode 会被重新赋值。这样trie.Hash()结束后,我们可以得到一个根节点 root 的 hashNode,它就是此时此刻这个 MPT结构的哈希值。
  • Header 中的成员变量 Root、TxHash、ReceiptHash 的生成,正是源于此。明显的,hashNode 体现了 MerkleTree 的特点:每个父节点的哈希值来源于所有子节点哈希值的组合,一个顶点的哈希值能够代表一整个树形结构。hashNode 加上之前的fullNode,shortNode,valueNode,构成了一个完整的 Merkle-PatriciaTrie 结构,很好的融合了各种原型结构的优点,非常值得研究。
  • 节点增删查改用到的函数都定义于 trie.go。在 MPT 的查找,插入,删除过程中,如果在遍历时遇到一个 hashNode,首先需要从数据库里以这个哈希值为 k,读取出相匹配的v,然后再将 v 解码恢复成 fullNode 或 shortNode。在代码中这个过程叫 resolve。

image.png

  • 五.StateDB
  • 在系统设计中,底层数据库模块和业务模型之间,往往需要设置本地存储模块,它面向业务模型,可以根据业务需求灵活的设计各种存储格式和单元,同时又连接底层数据库,如果底层数据库(或者第三方 API)有变动,可以大大减少对业务模块的影响。在以太坊中,StateDB 就担任这个角色,它通过大量的 stateObject 对象集合,管理所有“账户”信息。
  • StateDB 有一个 trie.Trie 类型成员 trie,它又被称为 storage trie 或 stte trie,这个 MPT 结构中存储的都是 stateObject 对象,每个 stateObject 对象以其地址(20 bytes)作为插入节点的 Key;每次在一个区块的交易开始执行前,trie 由一个哈希值(hashNode)恢复出来。另外还有一个 map 结构,也是存放 stateObject,每个 stateObject 的地址作为 map 的 key。那么问题来了,这些数据结构之间是怎样的关系呢?

image.png

  • 如上图所示,每当一个 stateObject 有改动,亦即“账户”信息有变动时,这个stateObject 对象会更新,并且这个 stateObject 会标为 dirty,此时所有的数据改动还仅仅存储在 map 里。当 IntermediateRoot()调用时,所有标为 dirty 的 stateObject 才会被一起写入 trie。而整个 trie 中的内容只有在 CommitTo()调用时被一起提交到底层数据库。可见,这个 map 被用作本地的一级缓存,trie 是二级缓存,底层数据库是第三级,各级数据结构的界限非常清晰,这样逐级缓存数据,每一级数据向上一级提交的时机也根据业务需求做了合理的选择。
  • StateDB 还可以管理账户状态的版本。这个功能用到了几个结构体:journal,revision,先来看看 UML 关系图:
  • 其中 journal 对象是 journalEntry 的散列,长度不固定,可任意添加元素,接口journalEntry 存在若干种实现体,描述了从单个账户操作(账户余额,发起合约次数等),到account trie 变化(创建新账户对象,账户消亡)等各种最小事件。revision 结构体,用来描述一个‘版本’,它的两个整型成员 jd 和 journalIndex,都是基于 journal 散列进行操作的。

image.png

  • 上图简述了 StateDB 中账户状态的版本是如何管理的。首先 journal 散列会随着系统运行不断的增长,记录所有发生过的单位事件;当某个时刻需要产生一个账户状态版本时, 代码中相应的是 Snapshop()调用,会产生一个新 revision 对象,记录下当前 journal 散列的长度,和一个自增 1 的版本号。
  • 基于以上的设计,当发生回退要求时,只要根据相应的 revision 中的 journalIndex,在journal 散列上,根据所记录的所有 journalEntry,即可使所有账户回退到那个状态。每个 stateObject 对象管理着以太坊中的一个“账户”。stateObject 有一个成员变量data,类型是 Accunt 结构体,里面存有账户 Ether 余额,合约发起次数,最新发起合约指令集的哈希值,以及一个 MPT 结构的顶点哈希值。
  • stateObject 内部也有一个 Trie 类型的成员 trie,被称为 storage trie,它里面存放的是一种被称为 State 的数据。State 跟每个账户相关,格式是[Hash, Hash]键值对。有意思的是,stateObject 内部也有类似 StateDB 一样的二级数据缓存机制,用来缓存和更新这些State。

image.png

  • stateObject 定义了一种类型名为 storage 的 map 结构,用来存放[Hash,Hash]类型的数据对,也就是 State 数据。当 SetState()调用发生时,storage 内部 State 数据被更新,相应标示为”dirty”。之后,待有需要时(比如 updateRoot()调用),那些标为”dirty”的 State 数据被一起写入 storage trie,而 storage trie 中的所有内容在 CommitTo()调用时再一起提交到底层数据库。

  • ETH交易部分分析

  • SEP 21th, 2018

  • 1.交易结构

  • 交易结构定义在 core/types/transaction.go 中: image.png

  • 这个 atomic 是 go 语言的一个包 sync/atomic,用来实现原子操作。在这个结构体中, data 为数据字段,其余三个为缓存。下面是计算 hash 的函数: image.png

  • 计算哈希前,首先会从缓存 tx.hash 中获取,如果取到,则直接返回值。没有,则使用

  • rlpHash 计算: image.png

  • hash 的计算方式为:先将交易的 tx.data 进行 rlpEncode 编码(定义在:core/types/transaction.go 中) image.png 然后再进行算法为 Keccak256 的哈希计算。即:txhash=Keccak256(rlpEncode(tx.data)) Transaction 中,data 为 txdata 类型的,定义于同文件中,里面详细规定了交易的具体字段: image.png

  • 这些字段的详细解释如下:

  • AccountNonce:此交易的发送者已发送过的交易数(可防止重放攻击)

  • Price:此交易的 gas price

  • GasLimit:本交易允许消耗的最大 gas 数量

  • Recipient:交易的接收者地址,如果这个字段为 nil 的话,则这个交易为“合约创建”类型交易

  • Amount:交易转移的以太币数量,单位是 wei

  • Payload:交易可以携带的数据,在不同类型的交易中有不同的含义

  • V R S:交易的签名数据

  • 我们会发现,交易中没有包含发送者地址这条数据,这是因为这个地址已包含在签名信息中,后面我们会分析到相关代码,另外,以太坊节点还会提供 JSON RPC 服务,供外部调用来传输数据。传输的数据格式为 json,因此,本文件中,还定义了交易的 json 类型数据结构,以及相关的转换函数。 image.png函数为:MarshalJSON()和 UnmarshlJSON(),这两个函数会调用core/types/gen_tx_json.go 文件中的同名函数进行内外部数据类型的转换。

2.交易存储 交易的获取与存储函数为:Get/WriteTXLookupEntries ,定义在 core/database_util.go中。 image.pngimage.pngimage.png

    • 对于每个传入的区块,该函数会读取块中的每一条交易来分别处理。首先建立条目(entry),数据类型为:txLookupEntry。内容包括区块哈希、区块号以及交易索引(交易 在区块中的位置),然后将此 entry 进行 rlp 编码作为存入数据库的 value。key 部分与区块存储类似,组成结构为交易前缀+交易哈希。
  • 此函数的调用主要在 core/blockchain.go 中,比如 WriteBlockAndState()会将区块写入数据库,处理 body 部分时需要分别处理每条交易。而 WriteBlockAndState 是在miner/worker.go 中 wait 函数调用的。mainer/worker.go 中 newWorker 函数在创建新矿工时,会调用 worker.wait().
  • 3.交易类型
  • 在源码中交易只有一种数据结构,如果非要给交易分个类的话,我认为交易可以分为三种:转账的交易、创建合约的交易、执行合约的交易。web3.js 提供了发送交易的接口:
  • web3.eth.sendTransaction(transactionObject [, callback]) (web3.js 在internal/jsre/deps 中)
  • 参数是一个对象,如果在发送交易的时候指定不同的字段,区块链节点就可以识别出对应类型的交易。
  • 转账交易: image.png
  • 最简单的一种交易,这里转账是指从一个账户向另一个账户发送以太币。发送转账交易的时候只需要指定交易的发送者、接收者、转币的数量。使用 web3.js 发送转账交易应该像这样:
  • value 是转移的以太币数量,单位是 wei,对应的是源码中的 Amount 字段。to 对应的是源码中的 Recipient
  • 创建合约交易*
  • ​ 创建合约指的是将合约部署到区块链上,这也是通过发送交易来实现。在创建合约的交易中,to 字段要留空不填,在 data 字段中指定合约的二进制代码,from 字段是交易的发送者也是合约的创建者。 image.png
  • data 字段对应的是源码中的 Payload 字段。
  • 执行合约交易:
  • 调用合约中的方法,需要将交易的 to 字段指定为要调用的合约的地址,通过 data 字段指定要调用的方法以及向该方法传递的参数。 image.png
  • data 字段需要特殊的编码规则,具体细节可以参考 Ethereum Contract ABI(自己拼接字段既不方便又容易出错,所以一般都使用封装好的 SDK(比如 web3.js) 来调用合约)。
  • 4.交易执行
    • ​ 按照以太坊架构设计,交易的执行可大致分为内外两层结构:第一层是虚拟机外,包括执行前将 Transaction 类型转化成 Message,创建虚拟机(EVM)对象,计算一些 Gas 消耗,以及执行交易完毕后创建收据(Receipt)对象并返回等;第二层是虚拟机内,包括执行 转帐,和创建合约并执行合约的指令数组。
  • 虚拟机外:
  • 执行 tx 的入口函数是 Process()函数,在 core/state_processor.go 中。 image.png
  • Process()函数的核心是一个 for 循环,它将 Block 里的所有 tx 逐个遍历执行。具体的执行函数为同个 go 文件中的 ApplyTransaction()函数,它每次执行 tx, 会返回一个收据(Receipt)对象。Receipt 结构体的声明如下(core/types/receipt.go): image.png
  • ​ Receipt 中有一个 Log 类型的数组,其中每一个 Log 对象记录了 Tx 中一小步的操作。所以,每一个 tx 的执行结果,由一个 Receipt 对象来表示;更详细的内容,由一组 Log 对象来记录。这个 Log 数组很重要,比如在不同 Ethereum 节点(Node)的相互同步过程中, 待同步区块的 Log 数组有助于验证同步中收到的 block 是否正确和完整,所以会被单独同步(传输)。
  • Receipt 的 PostState 保存了创建该 Receipt 对象时,整个 Block 内所有“帐户”的当时状态。Ethereum 里用 stateObject 来表示一个账户 Account,这个账户可转帐(transfer value), 可执行 tx, 它的唯一标示符是一个 Address 类型变量。 这个 Receipt.PostState 就是当时所在 Block 里所有 stateObject 对象的 RLP Hash 值。
  • Bloom 类型是一个 Ethereum 内部实现的一个 256bit 长 Bloom Filter。 Bloom Filter 概念定义可见 wikipedia,http://blog.csdn.net/jiaomeng/article/details/1495500 它可用来快速验证一个新收到的对象是否处于一个已知的大量对象集合之中。这里 Receipt 的 Bloom, 被用以验证某个给定的 Log 是否处于 Receipt 已有的 Log 数组中。
  • ​ 我们来看下 StateProcessor.ApplyTransaction()的具体实现,它的基本流程如下图: image.png
  • ApplyTransaction()首先根据输入参数分别封装出一个 Message 对象和一个 EVM 对象,然后加上一个传入的 GasPool 类型变量,执行 core/state_transition.go 中的ApplyMessage(),而这个函数又调用同 go 文件中 TransitionDb()函数完成 tx 的执行,待TransitionDb()返回之后,创建一个收据 Receipt 对象,最后返回该 Recetip 对象,以及整个tx 执行过程所消耗 Gas 数量。
  • GasPool 对象是在一个 Block 执行开始时创建,并在该 Block 内所有 tx 的执行过程中共享,对于一个 tx 的执行可视为“全局”存储对象; Message 由此次待执行的 tx 对象转化而来,并携带了解析出的 tx 的(转帐)转出方地址,属于待处理的数据对象;EVM 作为Ethereum 世界里的虚拟机(Virtual Machine),作为此次 tx 的实际执行者,完成转帐和合约(Contract)的相关操作。
  • 我们来细看下 TransitioinDb()的执行过程(/core/state_transition.go)。假设有StateTransition 对象 st, 其成员变量 initialGas 表示初始可用 Gas 数量,gas 表示即时可用Gas 数量,初始值均为 0,于是 st.TransitionDb() 可由以下步骤展开:
  • 首先执行 preCheck()函数,检查:1.交易中的 nonce 和账户 nonce 是否为同一个。2. 检查 gas 值是否合适(<=64 )
  • 购买 Gas。首先从交易的(转帐)转出方账户扣除一笔 Ether,费用等于tx.data.GasLimit * tx.data.Price; 同 时 st.initialGas = st.gas = tx.data.GasLimit; 然 后(GasPool) gp –= st.gas 。
  • 计算 tx 的固有 Gas 消耗 – intrinsicGas。它分为两个部分,每一个 tx 预设的消耗量,这个消耗量还因 tx 是否含有(转帐)转入方地址而略有不同;以及针对tx.data.Payload 的 Gas 消耗,Payload 类型是[]byte,关于它的固有消耗依赖于[]byte 中非 0 字节和 0 字节的长度。最终,st.gas –= intrinsicGas
  • EVM 执行。如果交易的(转帐)转入方地址(tx.data.Recipient)为空,即contractCreation,调用 EVM 的 Create()函数;否则,调用 Call()函数。无论哪个函数返回后,更新 st.gas。
  • 计算本次执行交易的实际 Gas 消耗: requiredGas = st.initialGas – st.gas
  • 偿退 Gas。它包括两个部分:首先将剩余 st.gas 折算成 Ether,归还给交易的(转帐)转出方账户;然后,基于实际消耗量 requiredGas,系统提供一定的补偿,数量为 refundGas。refundGas 所折算的 Ether 会被立即加在(转帐)转出方账户上, 同时 st.gas += refundGas,gp += st.gas,即剩余的 Gas 加上系统补偿的 Gas,被一起归并进 GasPool,供之后的交易执行使用。
  • 奖励所属区块的挖掘者:系统给所属区块的作者,亦即挖掘者账户,增加一笔金额,数额等于 st.data,Price * (st.initialGas – st.gas)。注意,这里的 st.gas 在步骤 5 中被加上了 refundGas, 所以这笔奖励金所对应的 Gas,其数量小于该交易实际消耗量 requiredGas。
  • 由上可见,除了步骤 3 中 EVM 函数的执行,其他每个步骤都在围绕着 Gas 消耗量作文章。
  • 步骤 5 的偿退机制很有意思,设立它的目的何在?目前为止我只能理解它可以避免交易执行过程中过快消耗 Gas,至于对其全面准确的理解尚需时日。
  • 步骤 6 是奖励机制,没什么好说的。
  • Ethereum 中每个交易(transaction,tx)对象在被放进 block 时,都是经过数字签名的, 这样可以在后续传输和处理中随时验证 tx 是否经过篡改。Ethereum 采用的数字签名是椭圆曲线数字签名算法(Elliptic Cure Digital Signature Algorithm,ECDSA)。ECDSA 相比于基于大质数分解的 RSA 数字签名算法,可以在提供相同安全级别(in bits)的同时,仅需更短的公钥(public key)。这里需要特别留意的是,tx 的转帐转出方(发送方)地址,就是对该 tx 对象作 ECDSA 签名计算时所用的公钥 publicKey。
  • Ethereum 中的数字签名计算过程所生成的签名(signature), 是一个长度为 65bytes 的字节数组,它被截成三段放进 tx 中,前 32bytes 赋值给成员变量 R, 再 32bytes 赋值给 S,1byte 赋给 V,当然由于 R、S、V 声明的类型都是*big.Int, 上述赋值存在[]byte –> big.Int 的类型转换。 image.png 当需要恢复出 tx 对象的转帐转出方地址时(比如在需要执行该交易时),Ethereum 会先从 tx 的 signature 中恢复出公钥,再将公钥转化成一个 common.Address 类型的地址,signature 由 tx 对象的三个成员变量 R,S,V 转化成字节数组[]byte 后拼接得到。 Ethereum 对此定义了一个接口 Signer, 用来执行挂载签名,恢复公钥,对 tx 对象做哈希等操作。 接口定义是在:/ core/types/transaction_signing.go 的: image.png 这个接口主要做的就是恢复发送地址、生成签名格式、生成交易哈希、验证等。

生成数字签名的函数叫 SignTx(),最根源是定义在 core/types/transaction_signing.go(mobile/accounts.go 中也有 SignTx,但是这个函数是调用 accounts/keystore/keystore.go中的 SignTX,最终又调用 types.SignTx),它会先调用其函数生成 signature, 然后调用tx.WithSignature()将 signature 分段赋值给 tx 的成员变量 R,S,V。 ​ Signer 接口中,恢复(提取?)转出方地址的函数为:Sender,Sender returns the address derived from the signature (V, R, S) using secp256k1。使用到的参数是:Signer 和 Transaction ,该函数定义在core/types/transaction_signing.go 中 image.png Sender()函数体中,signer.Sender()会从本次数字签名的签名字符串(signature)中恢复出公钥,并转化为 tx 的(转帐)转出方地址。此函数最终会调用同文件下的 recoverPlain 函数来进行恢复 在上文提到的 ApplyTransaction()实现中,Transaction 对象需要首先被转化成 Message接口,用到的AsMessage()函数即调用了此处的 Sender()。调用路径为: AsMessage->transaction_signing.Sender(两个参数的)–>sender(单个参数的) 在 Transaction 对象 tx 的转帐转出方地址被解析出以后,tx 就被完全转换成了Message 类型,可以提供给虚拟机 EVM 执行了。 虚拟机内: ​ 每个交易(Transaction)带有两部分内容(参数)需要执行: 转帐,由转出方地址向转入方地址转帐一笔以太币 Ether; 携带的[]byte 类型成员变量 Payload,其每一个 byte 都对应了一个单独虚拟机指令。这些内容都是由 EVM(Ethereum Virtual Machine)对象来完成 的。EVM 结构体是 Ethereum 虚拟机机制的核心,它与协同类的 UML 关系图如下: image.png

  • ​ 其中 Context 结构体分别携带了 Transaction 的信息(GasPrice, GasLimit),Block 的信息(Number, Difficulty),以及转帐函数等,提供给 EVM;StateDB 接口是针对 state.StateDB 结构体设计的本地行为接口,可为 EVM 提供 statedb 的相关操作; Interpreter 结构体作为解释器,用来解释执行 EVM 中合约(Contract)的指令(Code)。

  • ​ 注意,EVM 中定义的成员变量 Context 和 StateDB, 仅仅声明了变量名而无类型,而变量名同时又是其类型名,在 Golang 中,这种方式意味着宗主结构体可以直接调用该成员变量的所有方法和成员变量,比如 EVM 调用 Context 中的 Transfer()。

  • 交易的转帐操作由 Context 对象中的 TransferFunc 类型函数来实现,类似的函数类型,还有 CanTransferFunc, 和 GetHashFunc。这三个类型的函数变量 CanTransfer, Transfer, GetHash,在 Context 初始化时从外部传入,目前使用的均是一个本地实现。可见目前的转帐函数 Transfer()的逻辑非常简单,转帐的转出账户减掉一笔以太币,转入账户加上一笔以太币。由于 EVM 调用的 Transfer()函数实现完全由 Context 提供,所以,假设如果基于 Ethereum 平台开发,需要设计一种全新的“转帐”模式,那么只需写一个新的 Transfer()函数实现,在 Context 初始化时赋值即可。

  • 有朋友或许会问,这里 Transfer()函数中对转出和转入账户的操作会立即生效么?万一两步操作之间有错误发生怎么办?答案是不会立即生效。StateDB 并不是真正的数据库, 只是一行为类似数据库的结构体。它在内部以 Trie 的数据结构来管理各个基于地址的账 户,可以理解成一个 cache;当该账户的信息有变化时,变化先存储在 Trie 中。仅当整个Block 要被插入到 BlockChain 时,StateDB 里缓存的所有账户的所有改动,才会被真正的提交到底层数据库。

  • 合约的创建和赋值:

  • 合约(Contract)是 EVM 用来执行(虚拟机)指令的结构体。Contract 的结构定义于:core/vm/contract.go 中,在这些成员变量里,caller 是转帐转出方地址(账户),self 是转入方地址,不过它们的类型都用接口 ContractRef 来表示;Code 是指令数组,其中每一个 byte 都对应于一个预定义的虚拟机指令;CodeHash 是 Code 的 RLP 哈希值;Input 是数据数组,是指令所操作的数据集合;Args 是参数。

  • ​ 有意思的是 self 这个变量,为什么转入方地址要被命名成 self 呢? Contract 实现了ContractRef 接口,返回的恰恰就是这个 self 地址。

  • func (c *Contract) Address() common.Address { return c.self.Address()

  • }

  • ​ 所以当 Contract 对象作为一个 ContractRef 接口出现时,它返回的地址就是它的 self地址。那什么时候 Contract 会被类型转换成 ContractRef 呢?当 Contract A 调用另一个Contract B 时,A 就会作为 B 的 caller 成员变量出现。Contract 可以调用 Contract,这就为系统在业务上的潜在扩展,提供了空间。

  • 创建一个 Contract 对象时,重点关注对 self 的初始化,以及对 Code, CodeAddr 和Input 的赋值。

  • 另外,StateDB 提供方法 SetCode(),可以将指令数组 Code 存储在某个 stateObject 对象中; 方法 GetCode(),可以从某个 stateObject 对象中读取已有的指令数组 Code。

  • func (self *StateDB) SetCode(addr common.Address, code []byte) /func (self

  • *StateDB) GetCode(addr common.Address) code []byte

  • ​ stateObject (core/state/state_object.go)是 Ethereum 里用来管理一个账户所有信息修改的结构体,它以一个 Address 类型变量为唯一标示符。StateDB 在内部用一个巨大的map 结构来管理这些 stateObject 对象。所有账户信息-包括 Ether 余额,指令数组 Code,该账户发起合约次数 nonce 等-它们发生的所有变化,会首先缓存到 StateDB 里的某个stateObject 里,然后在合适的时候,被 StateDB 一起提交到底层数据库。

  • ​ EVM(core/vm/evm.go)中 目前有五个函数可以创建并执行 Contract,按照作用和调用方式,可以分成两类:

  • ​ Create(), Call(): 二者均在 StateProcessor 的 ApplyTransaction()被调用以执行单个交易,并且都有调用转帐函数完成转帐。

  • ​ CallCode(), DelegateCall(), StaticCall():三者由于分别对应于不同的虚拟机指令(1 byte)操作,不会用以执行单个交易,也都不能处理转帐。

  • 考虑到与执行交易的相关性,这里着重探讨 Create()和 Call()。先来看 Call(),它用来处理(转帐)转入方地址不为空的情况: image.png Call()函数的逻辑可以简单分为以上 6 步。其中步骤(3)调用了转帐函数 Transfer(),转入账户 caller, 转出账户 addr;步骤(4)创建一个 Contract 对象,并初始化其成员变量 caller, self(addr), value 和 gas; 步骤(5)赋值 Contract 对象的 Code, CodeHash, CodeAddr 成员变量;步骤(6) 调用 run()函数执行该合约的指令,最后 Call()函数返回。相关代码可见:

  • func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error)

  • {

  • if evm.vmConfig.NoRecursion && evm.depth > 0 {//如果设置了“禁用 call”,并且depth 正确,直接返回

  • return nil, gas, nil

  • }

  • // Fail if we’re trying to execute above the call depth limit

  • if evm.depth > int(params.CallCreateDepth) {//如果 call 的栈深度超过了预设值, 报错

  • return nil, gas, ErrDepth

  • }

  • // Fail if we’re trying to transfer more than the available balance

  • if !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {//检查发出账户是否有足够的钱(实际实现的函数定义在     core/evm.go/CanTransfer()中)但目前还不知道是怎么调用的
    
  • return nil, gas, ErrInsufficientBalance

  • }

  • var (

  • to = AccountRef(addr)

  • snapshot = evm.StateDB.Snapshot()

  • )

  • if !evm.StateDB.Exist(addr) {//建立账户

  • precompiles := PrecompiledContractsHomestead

  • if evm.ChainConfig().IsByzantium(evm.BlockNumber) { precompiles = PrecompiledContractsByzantium

  • }

  • if precompiles[addr] == nil && evm.ChainConfig().IsEIP158(evm.BlockNumber) && value.Sign() == 0 {

  • return nil, gas, nil

  • }

  • evm.StateDB.CreateAccount(addr)

  • }

  • evm.Transfer(evm.StateDB, caller.Address(), to.Address(), value)//转移

  • // initialise a new contract and set the code that is to be used by the

  • // E The contract is a scoped environment for this execution context

  • // only.

  • contract := NewContract(caller, to, value, gas)//建立合约contract.SetCallCode(&addr, evm.StateDB.GetCodeHash(addr),

  • evm.StateDB.GetCode(addr))

  • ret, err = run(evm, snapshot, contract, input)

  • // When an error was returned by the EVM or when setting the creation code

  • // above we revert to the snapshot and consume any gas remaining. Additionally

  • // when we’re in homestead this also counts for code storage gas errors. if err != nil {

  • evm.StateDB.RevertToSnapshot(snapshot) if err != errExecutionReverted {

  • contract.UseGas(contract.Gas)

  • }

  • }

  • return ret, contract.Gas, err

  • }

  • ​ 因为此时(转帐)转入地址不为空,所以直接将入参 addr 初始化 Contract 对象的 self 地址,并可从 StateDB 中(其实是以 addr 标识的账户 stateObject 对象)读取出相关的 Code 和CodeHash 并赋值给 contract 的成员变量。注意,此时转入方地址参数 addr 同时亦被赋值予 contract.CodeAddr。

  • 再来看看 EVM.Create(),它用来处理(转帐)转入方地址为空的情况。 image.png

  • 与 Call()相比,Create()因为没有 Address 类型的入参 addr,其流程有几处明显不同:

  • ​ 步骤(3)中创建一个新地址 contractAddr,作为(转帐)转入方地址,亦作为

  • Contract 的 self 地址;

  • ​ 步骤(6)由于 contracrAddr 刚刚新建,db 中尚无与该地址相关的 Code 信息, 所以会将类型为[]byte 的入参 code,赋值予 Contract 对象的 Code 成员;

  • ​ 步骤(8)将本次执行合约的返回结果,作为 contractAddr 所对应账户

  • (stateObject 对象)的 Code 储存起来,以备下次调用。

    • ​ 还有一点隐藏的比较深,Call()有一个入参 input 类型为[]byte,而 Create()有一个入参code 类型同样为[]byte,没有入参 input,它们之间有无关系?其实,它们来源都是Transaction 对象 tx 的成员变量 Payload!调用 EVM.Create()或 Call()的入口在StateTransition.TransitionDb()中,当 tx.Recipent 为空时,tx.data.Payload 被当作所创建Contract 的 Code;当 tx.Recipient 不为空时,tx.data.Payload 被当作 Contract 的 Input。
  • 预编译合约

  • ​ EVM 中执行合约(指令)的函数是 run(),在 core/vm/evm.go 中其实现代码如下: 可见如果待执行的 Contract 对象恰好属于一组预编译的合约集合-此时以指令地址CodeAddr 为匹配项-那么它可以直接运行;没有经过预编译的 Contract,才会由Interpreter 解释执行。这里的”预编译”,可理解为不需要编译(解释)指令(Code)。预编译的合约,其逻辑全部固定且已知,所以执行中不再需要 Code,仅需 Input 即可。

  • 在代码实现中,预编译合约只需实现两个方法 Required()和 Run()即可,这两方法仅需一个入参 input。

  • / core/vm/contracts.go

  • type PrecompiledContract interface { RequiredGas(input []byte) uint64 Run(input []byte) ([]byte, error)

  • }

  • func RunPrecompiledContract(p PrecompiledContract, input []byte, contract *Contrat) (ret []byte, err error) {

  • gas := p.RequiredGas(input) if contract.UseGas(gas) {

  • return p.Run(input)

  • }

  • return nil, ErrOutOfGas

  • }

  • 目前,Ethereuem 代码中已经加入了多个预编译合约,功能覆盖了包括椭圆曲线密钥恢复,SHA-3(256bits)哈希算法,RIPEMD-160 加密算法等等。相信基于自身业务的需求,二次开发者完全可以加入自己的预编译合约,大大加快合约的执行速度。

  • 解释器执行合约的指令

  • 解释器 Interpreter 用来执行(非预编译的)合约指令。它的结构体 UML 关系图如下所示: image.png

  • ​ Interpreter 结构体通过一个 Config 类型的成员变量,间接持有一个包括 256 个operation 对象在内的数组 JumpTable。operation 是做什么的呢?

  • 每个 operation 对象正对 应 一 个 已 定 义 的 虚 拟 机 指 令 , 它 所 含 有 的 四 个 函 数 变 量 execute, gasCost, validateStack, memorySize 提供了这个虚拟机指令所代表的所有操作。每个指令长度1byte,Contract 对象的成员变量 Code 类型为[]byte,就是这些虚拟机指令的任意集合,operation 对象的函数操作,主要会用到 Stack,Memory, IntPool 这几个自定义的数据结构。

    • ​ 这样一来,Interpreter 的 Run()函数就很好理解了,其核心流程就是逐个 byte 遍历入参 Contract 对象的 Code 变量,将其解释为一个已知的 operation,然后依次调用该operation 对象的四个函数,流程示意图如下:
  • image.png

  • operation 在操作过程中,会需要几个数据结构: Stack,实现了标准容器 -栈的行为;Memory,一个字节数组,可表示线性排列的任意数据;还有一个 intPool,提供对big.Int 数据的存储和读取。

  • 已定义的 operation,种类很丰富,包括:

  • ​ 算术运算:ADD,MUL,SUB,DIV,SDIV,MOD,SMOD,EXP…;

  • ​ 逻辑运算:LT,GT,EQ,ISZERO,AND,XOR,OR,NOT…;

  • ​ 业务功能:SHA3,ADDRESS,BALANCE,ORIGIN,CALLER,GASPRICE,LOG1,LOG2…等等

  • 需要特别注意的是 LOGn 指令操作,它用来创建 n 个 Log 对象,这里 n 最大是 4。还记得 Log 在何时被用到么?每个交易(Transaction,tx)执行完成后,会创建一个 Receipt 对象用来记录这个交易的执行结果。Receipt 携带一个 Log 数组,用来记录 tx 操作过程中的所有变动细节,而这些 Log,正是通过合适的 LOGn 指令-即合约指令数组(Contract.Code) 中的单个 byte,

未完,待续……去一个技术讲座^^

回到顶部