深入解析交易结构定义及hash计算方式,你知道多少?
2025-06-07 21:01 loading...
交易结构
交易结构的定义存在于core/types/transaction.go这个文件里:
这个atomic是go语言的一个包sync/atomic,其作用是实现原子操作。在这个结构体里,data是数据字段,其余三个是缓存。下面是计算hash的函数:
计算哈希之前,会先从缓存tx.hash中获取,若能取到,便直接返回值,若没有取到,就使用rlpHash进行计算。
hash的计算方式如下:首先要对交易的tx.data进行rlpEncode编码,该编码定义在core/types/transaction.go中
接着开展算法为Keccak256的哈希计算。也就是说,txhash等于对tx.data进行rlp编码后再进行Keccak256计算的结果。
在Transaction里,data属于txdata类型,它在同一个文件中被定义,其中详细规定了交易的具体字段:
这些字段的详细解释如下:
AccountNonce:它指的是此交易的发送者已经发送过的交易数量,这个数量能够起到防止重放攻击的作用。
Price:此交易的 gas price
GasLimit:本交易允许消耗的最大 gas 数量
接收者:是交易的接收者地址,若这个字段为 nil,那么这个交易就是“合约创建”类型的交易。
Amount:交易转移的以太币数量,单位是 wei
有效载荷是交易能够携带的数据,其在不同类型的交易里有着不同的含义 。
V R S:交易的签名数据
我们会发现,交易中没有包含发送者地址这条数据。这是因为这个地址已包含在签名信息中。后面我们会分析到相关代码。另外,以太坊节点还会提供JSON RPC服务,供外部调用来传输数据。传输的数据格式为json。因此,本文件中,还定义了交易的json类型数据结构,以及相关的转换函数。
函数有MarshalJSON()和UnmarshlJSON(),这两个函数会进行内外部数据类型的转换,它们会调用core/types/gen_tx_json.go文件中的同名函数 。
交易存储
交易的获取函数为:GetTXLookupEntries,交易的存储函数为:WriteTXLookupEntries,它们定义在core/database_util.go中。
对于每个传入的区块,该函数会读取块中的每一条交易,对其分别进行处理。首先建立条目,条目数据类型为txLookupEntry。条目内容包括区块哈希、区块号以及交易索引,交易索引指交易在区块中的位置。然后将此entry进行rlp编码,编码后的结果作为存入数据库的value。key 部分与区块存储类似,组成结构为交易前缀+交易哈希。
此函数的调用主要在core/blockchain.go中,比如WriteBlockAndState()会把区块写入数据库,处理body部分时要分别处理每条交易,WriteBlockAndState是在miner/worker.go中由wait函数调用的。在mainer/worker.go里,newWorker函数创建新矿工时,会调用worker.wait()
交易类型
在源码里进行交易仅有一类数据结构,要是一定要给交易划分种类,我觉得交易能够分成三种,分别是转账的交易,创建合约的交易,执行合约的交易。web3.js给出了发送交易的接口:
将交易对象发送交易,通过web3.eth.sendTransaction( )
, callback
web3.js存在于internal/jsre/deps当中
参数是一个对象,当发送交易时,若指定不同的字段,区块链节点便能识别出对应类型的交易。
转账交易:
转账是一种简单的交易,在这种交易里,转账意味着从一个账户向另一个账户发送以太币,发送转账交易时,只需指定交易的发送者、接收者以及转币的数量,使用web3.js发送转账交易应如此:
value是转移的以太币数量,其单位是wei,它对应的是源码中的Amount字段,to对应的是源码中的Recipient 。
创建合约交易:
创建合约是指把合约部署到区块链上,而这要通过发送交易来达成,在创建合约的交易里,to字段需留空不填,在data字段中要指定合约的二进制代码,from字段是交易的发送者,同时也是合约的创建者。
data 字段对应的是源码中的 Payload 字段。
执行合约交易:
调用合约中的方法,要把交易的to字段指定为要调用的合约的地址,要通过data字段指定要调用的方法,还要通过data字段指定向该方法传递的参数。
data字段需要特殊的编码规则,其具体细节可参考Ethereum Contract ABI,自己拼接字段既不方便又容易出错,因此一般都使用封装好的SDK来调用合约,比如web3.js 。
交易执行
按照以太坊架构设计,交易执行可大致分为内外两层结构。第一层是虚拟机外,这包括执行前将Transaction类型转化成Message,创建虚拟机(EVM)对象,计算一些Gas消耗,以及执行交易完毕后创建收据(Receipt)对象并返回等。第二层是虚拟机内,这包括执行转帐,和创建合约并执行合约的指令数组。
虚拟机外:
执行tx的入口函数是Process()函数,该函数在core/state_processor.go中 。
Process()函数的核心是一个for循环,这个for循环会逐个遍历执行Block里的所有tx,具体的执行函数是同个go文件中的ApplyTransaction()函数,该函数每次执行tx时,会返回一个收据(Receipt)对象。Receipt结构体进行声明,位置在(core/types/receipt.go) :
Receipt 中有一个数组,其类型为 Log,数组中的每一个 Log 对象记录了 Tx 中一小步的操作,所以每一个 tx 的执行结果由一个 Receipt 对象来表示,更详细的内容由一组 Log 对象来记录。这个Log数组十分关键,例如在不同Ethereum节点相互同步的进程里,待同步区块的Log数组对验证同步时收到的block是否准确且完整有帮助,因此会被单独同步(传输)。
Receipt的PostState进行了保存,保存的是创建该Receipt对象时的情况,当时整个Block内所有“帐户”都处于当时的状态 。在以太坊中,用状态对象来表示一个账户,该账户能够进行转账操作,还能够执行交易,其唯一标识符是一个地址类型的变量 。这个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()的具体实现情况,它的基本流程如下所示:
ApplyTransaction()首先依据输入参数分别封装出一个Message对象,再封装出一个EVM对象,接着加上一个传入的GasPool类型变量,随后执行core/state_transition.go中的ApplyMessage(),而这个函数又会调用同go文件中的TransitionDb()函数来完成tx的执行,等TransitionDb()返回之后,创建一个收据Receipt对象,最后返回该Receipt对象,以及整个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,其中initialGas表示初始可用Gas数量,gas表示即时可用Gas数量,且它们的初始值均为0。于是,st.TransitionDb()可通过以下步骤展开:
首先执行preCheck()函数,进行检查,一是检查交易中的nonce和账户nonce是否为同一个,二是检查gas值是否合适,涉及big.Int的类型转换。
当需要恢复tx对象的转帐转出方地址时,比如在需要执行该交易时,Ethereum会先从tx的signature中恢复出公钥,然后将公钥转化成一个common.Address类型的地址,signature是由tx对象的三个成员变量R、S、V转化成字节数组byte后拼接得到的。
以太坊针对此定义了一个接口Signer,该接口用于执行挂载签名,恢复公钥,对tx对象做哈希等操作。接口定义位于:/ core/types/transaction_signing.go :
这个接口主要做恢复发送地址的工作,还要生成签名格式,生成交易哈希,进行验证等。
生成数字签名的函数是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会使用secp256k1从签名(V,R,S)中得出地址。该函数用到的参数是Signer和Transaction,它定义在core/types/transaction_signing.go中
Sender()函数体中,signer.Sender()会从本次数字签名的签名字符串(signature)中恢复出公钥,公钥会被转化为tx的(转帐)转出方地址,此函数最终会调用同文件下的recoverPlain函数来进行恢复。
在上述提到的 ApplyTransaction()实现里,Transaction 对象要先被转化成 Message 接口,用到的 AsMessage()函数调用了此处的 Sender() 。调用路径为:AsMessage,transaction_signing.Sender(两个参数的),sender(单个参数的)。在Transaction对象tx的转帐转出方地址被解析出以后,tx就被完全转换成了Message类型,此时tx可以提供给虚拟机EVM执行了。
虚拟机内:
每个交易都带有两部分内容,这两部分内容也就是参数,是需要执行的 。
转帐,由转出方地址向转入方地址转帐一笔以太币 Ether;
携带byte类型成员变量Payload,Payload的每一个byte都对应一个单独虚拟机指令,这些内容由EVM(Ethereum Virtual Machine)对象完成,EVM结构体是Ethereum虚拟机机制的核心,它与协同类的UML关系图如下:
其中Context结构体携带了Transaction的信息,即GasPrice和GasLimit,还携带了Block的信息,即Number和Difficulty,以及转帐函数等,将这些信息提供给EVM;StateDB接口是针对state.StateDB结构体设计的本地行为接口,它可为EVM提供statedb的相关操作;Interpreter结构体作为解释器,用来解释执行EVM中合约的指令 。
注意,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里缓存的所有账户的所有改动,才会真正提交到底层数据库。
合约的创建和赋值:
合约是一种结构体,它被EVM用来执行指令,这里的指令指虚拟机指令。Contract的结构定义在core/vm/contract.go中,在这些成员变量里,caller是转帐转出方地址,也就是账户,self是转入方地址,不过它们的类型都用接口ContractRef来表示,Code是指令数组,其中每一个byte都对应一个预定义的虚拟机指令,CodeHash是Code的RLP哈希值,Input是数据数组,是指令所操作的数据集合,Args是参数。
有意思的是self这个变量,转入方地址为何要被命名成self呢,Contract实现了ContractRef接口,返回的地址恰恰就是这个self 。
对于结构体指针c所指向的Contract类型,调用其Address方法,返回c的成员变量self的Address方法的返回值,该返回值类型为common.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。
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字节)操作,所以不会用来执行单个交易,并且都不能处理转账。鉴于与执行交易的相关性,这里重点探讨Create()和Call()。先来看Call(),它用于处理一种情况,即(转帐)转入方地址不为空 。
Call()函数的逻辑可以简单分为以上 6 步。步骤(3)调用转帐函数Transfer(),将其转入账户设为caller,转出账户设为addr,步骤(4)创建一个Contract对象,并对其成员变量caller、self(addr)、value和gas进行初始化,步骤(5)对Contract对象的Code、CodeHash、CodeAddr成员变量进行赋值,步骤(6)调用run()函数执行该合约的指令,最后Call()函数返回。相关代码可见:
此时,转帐转入地址不为空,直接将入参addr初始化为Contract对象的self地址,可从StateDB中读取出相关的Code和CodeHash,StateDB其实是以addr标识的账户stateObject对象,将读取出的Code和CodeHash赋值给contract的成员变量。注意,此时转入方地址参数addr被赋值,赋值的对象是contract.CodeAddr 。
再来看看EVM.Create(),它用于处理一种情况,这种情况是(转帐)转入方地址为空 。
与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,Create()没有入参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 解释执行。这里所说的“预编译”,能够理解为不需要对指令进行编译(解释)。预编译的合约,其逻辑全部是固定的并且是已知的。所以在执行过程中不再需要指令,仅需要输入即可。
在代码实现里,预编译合约只要实现两个方法,分别是Required()和Run()就行,这两个方法只需要一个入参input 。
目前,Ethereuem 代码里已添加多个预编译合约,其功能涵盖椭圆曲线密钥恢复,SHA-3(256bits)哈希算法,RIPEMD-160 加密算法等。相信依据自身业务需求,二次开发者能够完全加入自己的预编译合约,这会极大加快合约的执行速度。
解释器执行合约的指令
解释器Interpreter用于执行合约指令,这些合约指令并非预编译的,它的结构体UML关系图如下所示:
Interpreter结构体借助一个Config类型的成员变量,间接持有一个数组JumpTable,该数组包含256个operation对象。operation是做什么的呢?
每个operation对象都对应一个已定义的虚拟机指令,该虚拟机指令所包含的四个函数变量execute、gasCost、validateStack、memorySize,提供了这个虚拟机指令所代表的所有操作。每个指令长度为1字节,Contract对象有成员变量Code,其类型是字节,Code是这些虚拟机指令的任意集合,operation对象进行函数操作,主要会用到Stack,Memory,IntPool这几个自定义的数据结构。
这样一来,Interpreter 的 Run()函数就很好理解了,其核心流程是逐个 byte 遍历入参 Contract 对象的 Code 变量,将该变量解释为一个已知的 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中读取该对象。
相关阅读
-
深入解析交易结构定义及hash计算方式,你知道多少?WEB3.0 2025-06-07 21:02