区块和交易,合约和虚拟机

2019-03-16  本文已影响0人  欧文Kira

更多请参考


基本概念

SHA-3哈希加密,RLP编码

常用数据类型 哈希值和地址

常用数据类型 哈希值和地址

Gas, 是Ethereum里对所有活动所消耗的资源的计量单位。这里的活动是泛化的概念,包括但不限于:转帐,合约的创建,合约指令的执行,执行中内存的扩展等等。所以Gas可以想象成现实中的汽油或者燃气。

Ether, 是Ethereum世界中使用的数字货币,也就是常说的以太币。如果某个帐号,Address A想要发起一个交易,比如一次简单的转帐,即向 Address B 发送一笔金额 H,那么Address A 本身拥有的Ether,除了转帐的数额 H 之外,还要有额外一笔金额用以支付交易所耗费的Gas。

如果可以实现Gas和Ether之间的换算(通过 GasPrice),那么Ethereum系统里所有的活动,都可以用Ether来计量。这样,Ether就有了点一般等价物,也就是货币的样子。

区块是交易的集合

区块(Block)是Ethereum的核心结构体之一。在整个区块链(BlockChain)中,一个个Block是以单向链表的形式相互关联起来的。Block中带有一个Header(指针), Header结构体带有Block的所有属性信息,其中的ParentHash 表示该区块的父区块哈希值, 亦即Block之间关联起来的前向指针。只不过要想得到父区块(parentBlock)对象,需要将ParentHash同其他字符串([]byte)组合成合适的key([]byte), 去kv数据库里查询相应的value才能解析得到。

Block中还有一个Tranction(指针)数组,这是我们这里关注的。Transaction(简称tx),是Ethereum里标示一次交易的结构体, 它的成员变量包括转帐金额,转入方地址等等信息。Transaction的完整声明如下:

转帐转入方地址Recipient可能为空(nil),这时在后续执行tx过程中,Ethereum 需要创建一个地址来完成这笔转帐。Payload是重要的数据成员,它既可以作为所创建合约的指令数组,其中每一个byte作为一个单独的虚拟机指令;也可以作为数据数组,由合约指令进行操作。合约由以太坊虚拟机(Ethereum Virtual Machine, EVM)创建并执行。

交易的执行

Ethereum 中交易的执行可大致分为内外两层结构:第一层是虚拟机外,包括执行前将Transaction类型转化成Message,创建虚拟机(EVM)对象,计算一些Gas消耗,以及执行交易完毕后创建收据(Receipt)对象并返回等;第二层是虚拟机内,包括执行转帐,和创建合约并执行合约的指令数组。

虚拟机外

入口和返回值

执行tx的入口函数是StateProcessor的Process()函数,其实现代码如下:

GasPool 类型其实就是big.Int,其初始值为区块的 gasLimit 。在一个Block的处理过程(即其所有tx的执行过程)中,GasPool 的值能够告诉你,剩下还有多少Gas可以使用,其实也可以间接反应该Block是否还可以打包交易。在每一个tx执行过程中,Ethereum 还设计了偿退(refund)环节,所偿退的Gas数量也会加到这个GasPool里。

Process()函数的核心是一个for循环,它将Block里的所有tx逐个遍历执行。具体的执行函数叫ApplyTransaction(),它每次执行tx, 会返回一个收据(Receipt)对象

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,它可用来快速验证一个新收到的对象是否处于一个已知的大量对象集合之中。这里Receipt的Bloom,被用以验证某个给定的Log是否处于Receipt已有的Log数组中

消耗Gas,亦奖励Gas

ApplyTransaction()首先根据输入参数分别封装出一个Message对象和一个EVM对象,然后加上一个传入的GasPool类型变量,由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() 可由以下步骤展开:

交易的数字签名

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。

当需要恢复出tx对象的转帐转出方地址时(比如在需要执行该交易时),Ethereum 会先从tx的signature中恢复出公钥,再将公钥转化成一个common.Address类型的地址,signature由tx对象的三个成员变量R,S,V转化成字节数组[]byte后拼接得到。

在Transaction对象tx的转帐转出方地址被解析出以后,tx 就被完全转换成了Message类型,可以提供给虚拟机EVM执行了。

虚拟机内

每个交易(Transaction)带有两部分内容需要执行:1. 转帐,由转出方地址向转入方地址转帐一笔以太币Ether; 2. 携带的[]byte类型成员变量Payload,其每一个byte都对应了一个单独虚拟机指令。这些内容都是由EVM(Ethereum Virtual Machine)对象来完成的。

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

完成转帐

交易的转帐操作由Context对象中的TransferFunc类型函数来实现,类似的函数类型,还有 CanTransferFunc, 和 GetHashFunc。

core/vm/evm.go
type {
CanTransferFunc func(StateDB, common.Address, *big.Int)
TransferFunc func(StateDB, common.Address, common.Address, *big.Int)
GetHashFunc func(uint64) common.Hash
}

这三个类型的函数变量CanTransfer, Transfer, GetHash,在Context初始化时从外部传入,目前使用的均是一个本地实现:

// core/evm.go
func NewEVMContext(msg Message, header *Header, chain ChainContext, author *Address){
return vm.Context {
CanTransfer: CanTransfer,
Transfer: Transfer,
GetHash: GetHash(header, chain),
...
}
}

func CanTransfer(db vm.StateDB, addr common.Address, amount *big.Int) {
return db.GetBalance(addr).Cmp(amount) >= 0
}

func Transfer(db vm.StateDB, sender, recipient common.Address, amount *big.Int) {
db.SubBalance(sender, amount)
db.AddBalance(recipient, amount)
}

可见目前的转帐函数Transfer()的逻辑非常简单,转帐的转出账户减掉一笔以太币,转入账户加上一笔以太币。由于EVM调用的Transfer()函数实现完全由Context提供,所以,假设如果基于Ethereum平台开发,需要设计一种全新的“转帐”模式,那么只需写一个新的Transfer()函数实现,在Context初始化时赋值即可。

需要注意的是这里的StateDB 并不是真正的数据库,只是一行为类似数据库的结构体。它在内部以Trie的数据结构来管理各个基于地址的账户,可以理解成一个cache;当该账户的信息有变化时,变化先存储在Trie中。仅当整个Block要被插入到BlockChain时,StateDB 里缓存的所有账户的所有改动,才会被真正的提交到底层数据库。

合约的创建和赋值

合约(Contract)是EVM用来执行(虚拟机)指令的结构体。先来看下Contract的定义:

// core/vm/contract.go
type ContractRef interface {
Address() common.Address
}
type Contract struct {
CallerAddress common.Address
caller ContractRef
self ContractRef

jumpdests destinations
Code []byte
CodeHash common.Hash
CodeAddr *Address
Input []byte
Gas uint64
value *big.Int
Args []byte
DelegateCall bool

}

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

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

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

stateObject 是Ethereum里用来管理一个账户所有信息修改的结构体,它以一个Address类型变量为唯一标示符。StateDB 在内部用一个巨大的map结构来管理这些stateObject对象。所有账户信息-包括Ether余额,指令数组Code, 该账户发起合约次数nonce等-它们发生的所有变化,会首先缓存到StateDB里的某个stateObject里,然后在合适的时候,被StateDB一起提交到底层数据库。注意,一个Contract所对应的stateObject的地址,是Contract的self地址,也就是转帐的转入方地址。

EVM 目前有五个函数可以创建并执行Contract,按照作用和调用方式,可以分成两类:

Call(),它用来处理(转帐)转入方地址不为空的情况,EVM.Create(),它用来处理(转帐)转入方地址为空的情况

有一点隐藏的比较深,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

预编译的合约

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

解释器执行合约的指令

解释器Interpreter用来执行(非预编译的)合约指令。Interpreter结构体通过一个Config类型的成员变量,间接持有一个包括256个operation对象在内的数组JumpTable。operation是做什么的呢?每个operation对象正对应一个已定义的虚拟机指令,每个operation对像所含有的四个函数变量execute, gasCost, validateStack, memorySize 提供了这个虚拟机指令所代表的所有操作。每个指令长度1byte,Contract对象的成员变量Code类型为[]byte,就是这些虚拟机指令的任意集合。operation对象的函数操作,主要会用到Stack,Memory, IntPool 这几个自定义的数据结构。

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

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

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

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

小结

以太坊的出现大大晚于比特币,虽然明显受到比特币系统的启发,但在整个功能定位和设计架构上却做了很多更广更深的思考和尝试。以太坊更像是一个经济活动平台,而并不局限一种去中心化数字代币的产生,分发和流转。本文从交易执行的角度切入以太坊的系统实现,希望能提供一点管中窥豹的作用。

上一篇 下一篇

猜你喜欢

热点阅读