更多请参考
基本概念
SHA-3哈希加密,RLP编码
Ethereum 中用到的哈希函数全部采用SHA-3(Secure Hash Algorithm 3),SHA-3在2015年8月由美国标准技术协会(NIST)正式发布,作为Secure Hash Algorithm家族的最新一代标准,它相比于SHA-2和SHA-1,采用了完全不同的设计思路,性能也比较好。
RLP(Recursive Length Prefix)编码,它可以将一个任意嵌套的字节数组([]byte),编码成一个“展平”无嵌套的[]byte。RLP 编码其实提供了一种序列化的编码方法,无论输入是何种嵌套形式的元素或数组,编码输出形式都是[]byte。RLP是可逆的,它提供了互逆的编码、解码方法。
Ethereum 中具体使用的哈希算法,就是对某个类型对象的RLP编码值做了SHA3哈希运算,可称为RLP Hash。 Ethereum 在底层存储中特意选择了专门存储和读取[k, v] 键值对的第三方数据库,[k, v] 中的v 就是某个结构体对象的RLP编码值([]byte),k大多数情况就是v的RLP编码后的SHA-3哈希值。
常用数据类型 哈希值和地址
- 在Ethereum 代码里,所有用到的哈希值,都使用该Hash类型,长度为32bytes,即256 bits;Ethereum 中所有跟帐号(Account)相关的信息,比如交易转帐的转出帐号(地址)和转入帐号(地址),都会用该Address类型表示,长度20bytes,即160 bits。
- big.Int是golang提供的数据类型,用来处理比较大的整型数,当然它也可以处理诸如64bit,32bit的常用整数。
常用数据类型 哈希值和地址
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的完整声明如下:
- /core/types/transaction.go
type Transaction struct {
data txdata
hash, size, from atomic.Value // for cache
}
type txdata struct {
AccountNonce uint64
Price *big.Int // 单位Gas消耗所折抵的Ether多少
GasLimit *big.Int // tx执行过程中所允许消耗资源的总上限
Recipient *common.Address
Amount *big.Int
Payload []byte
V, R, S *big.Int // for signature
Hash *common.Hash // for marshaling
}
转帐转入方地址Recipient可能为空(nil),这时在后续执行tx过程中,Ethereum 需要创建一个地址来完成这笔转帐。Payload是重要的数据成员,它既可以作为所创建合约的指令数组,其中每一个byte作为一个单独的虚拟机指令;也可以作为数据数组,由合约指令进行操作。合约由以太坊虚拟机(Ethereum Virtual Machine, EVM)创建并执行。
交易的执行
Ethereum 中交易的执行可大致分为内外两层结构:第一层是虚拟机外,包括执行前将Transaction类型转化成Message,创建虚拟机(EVM)对象,计算一些Gas消耗,以及执行交易完毕后创建收据(Receipt)对象并返回等;第二层是虚拟机内,包括执行转帐,和创建合约并执行合约的指令数组。
虚拟机外
入口和返回值
执行tx的入口函数是StateProcessor的Process()函数,其实现代码如下:
- /core/state_processor.go
func (p *StateProcessor) Process(block *Block, statedb StateDB, cfg vm.Config) (types.Receipts, []types.Log, big.Int, error) {
var {
receipts types.Receipts
totalUsedGas = big.NewInt(0)
header = block.Header()
allLogs []types.Log
gp = new(GasPool).AddGas(block.GasLimit())
}
...
for i, tx := range block.Transactions() {
statedb.Prepare(tx.Hash(), block.Hash(), i)
receipt, _, err := ApplyTransaction(p.config, p.bc, author:nil, gp, statedb, header, tx, totalUsedGas, cfg)
if err != nil { return nil, nil, nil, err}
receipts = append(receipts, receipt)
allLogs = append(allLogs, receipt.Logs...)
}
p.engine.Finalize(p.bc, header, statedb, block.Transactions(), block.Uncles(), receipts)
return receipts, allLogs, totalUsedGas, nil
}
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() 可由以下步骤展开:
购买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)为空,调用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。
交易的数字签名
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,按照作用和调用方式,可以分成两类:
Create(), Call(): 二者均在StateProcessor的ApplyTransaction()被调用以执行单个交易,并且都有调用转帐函数完成转帐。
CallCode(), DelegateCall(), StaticCall():三者由于分别对应于不同的虚拟机指令(1 byte)操作,不会用以执行单个交易,也都不能处理转帐。
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,种类很丰富,包括:
- 算术运算: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,在其对应的operation里被创建出来的。每个新创建的Log对象被缓存在StateDB中的相对应的stateObject里,待需要时从StateDB中读取。
小结
以太坊的出现大大晚于比特币,虽然明显受到比特币系统的启发,但在整个功能定位和设计架构上却做了很多更广更深的思考和尝试。以太坊更像是一个经济活动平台,而并不局限一种去中心化数字代币的产生,分发和流转。本文从交易执行的角度切入以太坊的系统实现,希望能提供一点管中窥豹的作用。
- Gas是Ethereum系统的血液。一切资源,活动,交互的开销,都以Gas为计量单元。如果定义了一个GasPrice,那么所有的Gas消耗亦可等价于以太币Ether。
- Block是Transaction的集合。Block在插入BlockChain前,需要将所有Transaction逐个执行。Transaction的执行会消耗发起方的Ether,但系统在其执行完成时,会给予其作者(挖掘出这个Block的账户)一笔补偿,这笔补偿是“矿工”赚取收入的来源之一。
- Ethereum 定义了自己的虚拟机EVM, 它与合约(Contract)机制相结合,能够在提供非常丰富的操作的同时,又能很好的控制存储空间和运行速度。Contract由Transaction转化得到。
- Ethereum 里的哈希函数,用的是SHA-3,256 bits;数据(数组)的序列化,用的是RLP编码,所以所有对象,数组的哈希算法,实际用的RLP + SHA-3。数字签名算法,使用了椭圆曲线数字签名算法(ECDSA)。