以太坊虚拟机(EVM)设计原理 - 状态转移源码分析
WarderPANgo
2023-12-04 14:11
订阅此专栏
收藏此文章

EVM 地址

普通账户地址:EOA(External Owner Account),可以持有私钥,可以发送交易的账户地址。通常使用钱包进行创建。

合约账户地址:由创建合约的 EOA 地址 + 交易的 nonce 值,计算哈希后取第 12~31 共 20 个字节。

通过钱包进行 EOA 生成

下面通过钱包派生一个 EOA 地址的函数,通过第代码片段的 993 行可以看到,其输入参数包含公钥信息,和派生路径信息。 

从下面的代码片段可以看出,通过钱包进行 EOA 地址生成时,地址的获取,是和公钥直接相关的,对公钥进行哈希后,取了第 12 到 31 个字节的数据。

合约地址生成

下面的代码片段是节点接收到区块后,进行交易执行时操作逻辑。函数将区块中的每一笔交易信息转换成‘msg’这种结构。 

从上面代码片段中的第 82 行进入函数‘applyTransaction’,该函数会对 msg 中的目的地址字段‘to’进行判断,如果目的地之字段为空,那么协议就认为给笔交易是一个合约创建交易。接下来就要调用合约地址创建函数。 

从下面的代码片段我们可以看到,合约地址的生成是将交易的 sender 地址,以及此笔交易的 nonce 值,进行哈希后,取第 12 到 31 个字节。 


Optional AccessList(EIP2930)

EVM 执行区块中的交易,进行状态转移时,将 transaction 都转化为 msg,msg 的类型如下,其中有一个 accessList 成员变量。

AccessList 说明

包含一个 AccessList,指明一组 address 以及对应于每个 address 要访问的一组 storage keys。这些地址和存储 key 被添加到 accessed_addresses 和 accessed_storage_keys 全局集合中(在 EIP-2929 中引入)。

通过 AccessList 声明要访问的数据,可以节省 gas 费用。

AccessList 之外的数据也可以访问,但是 gas 费用比较高。

Address 或者 AccessList 中的 key 目前是可以重复,如果重复了会重复收费,没有其他不同

类型 AccessList 的结构如下:

在发送交易时提前声明要访问的数据。规定了格式:每个地址对应多个 key 值。如下图所示:

由于新增了 AccessList,交易的大小会比没有 AccessList 更大。也就会增加 gas 消耗,每个 key 值固定消耗 1900wei,每个 address 固定消耗 2400wei。

不过,当存储的读取可以预测时,处理交易更容易。因为 clients 可以预加载数据,并行读取数据。此外,在一些场景中 AccessList 难以实时构建,在交易生成和签名之间存在长时间滞后。目前,只有 10% 的折扣,将来会考虑提高 list 之外的 key 的费用。发送交易前,可以通过 API 进行进行创建 AccessList,同时会返回该交易对应的 gas 消耗。


EVM 数据存储位置

EVM 的数据存储有以下几类:

Memory(线性地址空间)

Stack

Trie (Merkle-Tree ,stateDB)

Ancient 存储(只能追加,不能修改)

LevelDB

其中 Memory、Stack、Trie 和 LevelDB 都很好理解其作用。第 4 条 Ancient 存储不好理解,经过走读代码,发现起作用是进行冷存储的。将历史区块数据进行保存。

表示单链数据表(例如块)。它由一个数据文件(snappy 编码的任意数据 blob)和一个 索引入口 文件(指向数据文件的未压缩的 64 位索引)组成。从下面的代码片段可以看到,保存了区块哈希表、区块头表、区块 body 表、收据表还有一个困难度表。


状态转移与 Gas 计算

①状态转移前的检查工作

检查交易信息是否满足共识规则:

检查 nonce 值是否正确(等于状态数据库中的 nonce 值,且 +1 后不大于 2^64)

调用者有足够的余额(balance > gaslimit * gasprice)

当前区块可用 Gas 量可以供当前交易消耗的(当前交易的 MaxFeePerGas > 当前区块的 BaseFee)。

当前交易中支付的 gas 大于 固有 Gas 费(data 占用)

②固有 Gas 不能溢出(max:2^64)

检查账户余额 > 转账金额(msg 的 value 字段)

固有 Gas 费计算

Initial gas=53000( 创建合约 ) 或者 21000(其他)

gas += data 字段的 NoneZero 字节数量 * 68(EIP2028:16)

gas += data 字段的 Zero 字节数量 * 4

gas += len(accessList) * 2400

gas += len(accessList. StorageKeys) * 1900

合约调用

交易的类型分为转账、创建合约、调用合约。

合约执行函数逻辑如下图所示,关注点有

先检查合约嵌套调用的深度,不能超过 1024。

判断是否是内建合约

执行实际的转账操作

内建合约的代码不需要从 StateDB 中读取,直接调用

合约执行后,即时出错了,gas 的消耗不会退回,依然要背减掉。



内建合约

内建合约根据不同协议版本,有一点差别。大致有以下版本:

PrecompiledContractsHomestead

PrecompiledContractsByzantium

PrecompiledContractsIstanbul

PrecompiledContractsBerlin

PrecompiledContractsBLS(测试使用)


其中,合约函数的功能描述如下:

ecrecover: 返回 ecdsa 签名的公钥

sha256hash: 计算哈希值

ripemd160hash: 计算哈希值

dataCopy:数据拷贝

bigModExp:大整数指数模运算

bn256AddByzantium:椭圆曲线点加

bn256ScalarMulByzantium:椭圆曲线标量乘法

bn256PairingByzantium:bn256 曲线实现配对

blake2F:快速安全的哈希算法。BLAKE2 is specified in RFC 7693

合约执行

合约的执行就是将操作码转换成指令集中的指令,一条条执行的过程。不同版本的协议,指令集有所不同。不同指令集的版本如下:

下面的代码片段是合约执行的逻辑。程序计数器从 0 开始,不断的取出操作码 opcode,然后根据 opcode 找到对应的操作 operation。其中 operation 中就包括了 gas 消耗、需要的 Stack 大小、Memory 大小、执行函数。每个操作的 gas 消耗包含固定 gas 消耗和动态 gas 消耗,动态 gas 消耗通过动态 gas 计算函数进行计算得出。


操作码执行举例

CALLDATALOAD:把交易的 input 字段中的第 4 字节之后的 32 字节入栈。Get inputdata in current environment

SLOAD:根据关键字 key,加载 StateDB 中的值 value

SSTORE:根据关键字,保存 value 到 StateDB


栈操作演示

假设,i=2,num=5,合约函数如下:

编译后的操作码:

Stack 的执行过程演示如下,栈顶是输入参数 i = 2,栈顶下面的元素忽略为 x。

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

WarderPANgo
数据请求中
查看更多

推荐专栏

数据请求中
在 App 打开