以太坊 交易结构、执行、存储

一、 交易的结构

1. Transaction结构

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

type Transaction struct {
    //交易数据
    data txdata

    // caches
    hash atomic.Value
    size atomic.Value

    //钱包根据 from来找到 
    //account := accounts.Account{Address: args.From}
    from atomic.Value
}

(1)data

data 为数据字段 txdata 类型,其余三个为缓存。

(2)hash

atomic 是 go 语言的一个包 sync/atomic,用来实现原子操作。下面是计算hash的函数:

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

hash 的计算方式为:先将交易的 tx.data 进行 rlpEncode 编码(位于:core/types/transaction.go 。

然后再进行算法为 Keccak256 的哈希计算。即:txhash=Keccak256(rlpEncode(tx.data))

2. txdata结构

type txdata struct {
    //发送者发起的交易总数
    AccountNonce uint64          `json:"nonce"    gencodec:"required"`
    //交易的Gas价格
    Price        *big.Int        `json:"gasPrice" gencodec:"required"`
    //交易允许消耗的最大Gas
    GasLimit     uint64          `json:"gas"      gencodec:"required"`
    //交易接收者地址
    Recipient    *common.Address `json:"to"       rlp:"nil"` // nil means contract creation
    //交易额
    Amount       *big.Int        `json:"value"    gencodec:"required"`
    //其他数据
    Payload      []byte          `json:"input"    gencodec:"required"`

    // Signature values
    // 交易相关签名数据
    V *big.Int `json:"v" gencodec:"required"`
    R *big.Int `json:"r" gencodec:"required"`
    S *big.Int `json:"s" gencodec:"required"`

    // This is only used when marshaling to JSON.
    //交易HAsh
    Hash *common.Hash `json:"hash" rlp:"-"`
}
  • nonce: 交易发起者填写的序列号,代表此交易的发送者已发送过的交易数(可防止重放攻击)
  • Gasprice: 交易者愿意支付的Gas价格
  • Startgas:交易者愿意花费的最大Gas数量,本交易允许消耗的最大 gas 数量
  • To: 交易接收方地址(20Bytes),如果这个字段为 nil 的话,则这个交易为“合约创建”类型交易
  • Value: 交易转移的以太币数量,单位是 wei
  • startgas*gasprice = 最终ether数值
  • Data:交易可以携带的数据,可变长度的二进制数据载荷,在不同类型的交易中有不同的含义
  • V,R,S: 交易的签名数据,ECDSA数字签名信息

3. 交易中没有包含发送者地址这条数据

这是因为这个地址已包含在签名信息中。

二、交易的类型

在源码中交易只有一种数据结构,如果非要给交易分个类的话,我认为交易可以分为三种:

  • 转账的交易、
  • 创建合约的交易、
  • 执行合约的交易。

1. 发送交易的接口

web3.js 提供了发送交易的接口:

web3.eth.sendTransaction(transactionObject [, callback]) (web3.js 在internal/jsre/deps 中)

参数是一个对象,如果在发送交易的时候指定不同的字段,区块链节点就可以识别出对应类型的交易。

2. 转账交易

​转账是最简单的一种交易,这里转账是指从一个账户向另一个账户发送以太币。发送转账交易的时候只需要指定交易的发送者、接收者、转币的数量。

使用 web3.js 发送转账交易应该像这样:

value 是转移的以太币数量,单位是 wei,对应的是源码中的 Amount 字段。to 对应的是源码中的 Recipien。

3. 创建合约交易

​ 创建合约指的是将合约部署到区块链上,这也是通过发送交易来实现。

在创建合约的交易中,to 字段要留空不填,在 data 字段中指定合约的二进制代码,from 字段是交易的发送者也是合约的创建者。

data 字段对应的是源码中的 Payload 字段。

4. 执行合约交易

调用合约中的方法,需要将交易的 to 字段指定为要调用的合约的地址,通过 data 字段指定要调用的方法以及向该方法传递的参数。

data 字段需要特殊的编码规则,具体细节可以参考 Ethereum Contract ABI(自己拼接字段既不方便又容易出错,所以一般都使用封装好的 SDK(比如 web3.js) 来调用合约)。

实际上,一段智能合约是被唯一的(合约)地址所标识,该地址有自己的资金余额(以太币),并且一经检索到有一笔交易发送至该(合约)地址,以太坊网络节点就会执行合约逻辑。以太坊自带执行引擎,即以太坊虚拟机(EVM)来执行智能合约。

 

三、 创建交易

1. 创建交易时要提交的数据

代码位于: internal/ethapi/api.go。

// SendTxArgs represents the arguments to sumbit a new transaction into the transaction pool.
type SendTxArgs struct {
    From     common.Address  `json:"from"`
    To       *common.Address `json:"to"`
    Gas      *hexutil.Uint64 `json:"gas"`
    GasPrice *hexutil.Big    `json:"gasPrice"`
    Value    *hexutil.Big    `json:"value"`
    Nonce    *hexutil.Uint64 `json:"nonce"`
    // We accept "data" and "input" for backwards-compatibility reasons. "input" is the
    // newer name and should be preferred by clients.
    Data  *hexutil.Bytes `json:"data"`
    Input *hexutil.Bytes `json:"input"`
}

2. 交易的 value 和 data

  • 交易的主要“有效负载”包含在两个字段中:value 和 data。交易可以同时有 value 和 data,仅有 value,仅有 data,或者既没有 value 也没有 data。所有四种组合都有效。
  • 仅有 value 的交易就是一笔以太的付款
  • 仅有 data 的交易一般是合约调用
  • 进行合约调用的同时,我们除了传输 data, 还可以发送以太,从而交易中同时包含 data 和 value
  • 没有 value 也没有 data 的交易,只是在浪费 gas,但它是有效的

注意:以太坊单笔交易运行携带最大数据量为44KB

四、交易执行

1. 以太坊架构设计

按照以太坊架构设计,交易的执行可大致分为内外两层结构:

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

2. 虚拟机外

执行 tx 的入口函数是 Process()函数,在 core/state_processor.go 中。

​ Process()函数的核心是一个 for 循环,它将 Block 里的所有 tx 逐个遍历执行。

具体的执行函数为同个 go 文件中的 ApplyTransaction()函数,它每次执行 tx, 会返回一个收据(Receipt)对象。

Receipt 结构体的声明如下(core/types/receipt.go):

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

​我们来看下 StateProcessor.ApplyTransaction()的具体实现,它的基本流程如下图:

​ 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 的类型转换。

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

Ethereum 对此定义了一个接口 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 returns the address derived from the signature (V, R, S) using secp256k1。使用到的参数是: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 类型,可以提供给虚拟机 EVM 执行了。

3. 虚拟机内

​ 每个交易(Transaction)带有两部分内容(参数)需要执行:

  • 转帐,由转出方地址向转入方地址转帐一笔以太币 Ether。
  • 携带的[]byte 类型成员变量 Payload,其每一个 byte 都对应了一个单独虚拟机指令。这些内容都是由 EVM(Ethereum Virtual Machine)对象来完成 的。

EVM 结构体是 Ethereum 虚拟机机制的核心,它与协同类的 UML 关系图如下:

​ 其中 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 里缓存的所有账户的所有改动,才会被真正的提交到底层数据库。

五、交易存储

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

对于每个传入的区块,该函数会读取块中的每一条交易来分别处理。

  • 首先建立条目(entry),数据类型为:txLookupEntry。内容包括区块哈希、区块号以及交易索引(交易 在区块中的位置),
  • 然后将此 entry 进行 rlp 编码作为存入数据库的 value。key 部分与区块存储类似,组成结构为交易前缀+交易哈希。

此函数的调用主要在 core/blockchain.go 中,比如 WriteBlockAndState()会将区块写入数据库,处理 body 部分时需要分别处理每条交易。

而 WriteBlockAndState 是在miner/worker.go 中 wait 函数调用的。

mainer/worker.go 中 newWorker 函数在创建新矿工时,会调用 worker.wait()。

下一章:以太坊 如何计算难度difficulty

1. 什么是难度难度(Difficulty) 来源于区块链技术的先驱比特币,用来度量挖出一个区块平均需要的运算次数。挖矿本质上就是在求解一个谜题,不同的电子币设置了不同的谜题。比如比特币使用 SHA ...