EVM
Evm 是实现以太坊虚拟机(EVM)的主要结构,它是一个基于栈的用于执行以太坊智能合约的虚拟机。
内部构成
它主要由两部分组成:Context 和 Handler。Context 表示执行所需的状态,而 Handler 包含作为逻辑的函数列表。
Context 进一步分为 EvmContext 和 External 上下文。EvmContext 是内部的,包含 Database、Environment、JournaledState 和 Precompiles。而 External 上下文完全通用,没有任何特征限制,其目的是允许自定义处理程序在运行时保存状态或允许添加钩子(例如,外部上下文可以是检查器),在 EvmBuilder 中可以看到更多关于其用法的信息。
Evm 实现了 Host 特征,该特征定义了 EVM 解释器与其环境(或"主机")交互的接口,包括账户和存储访问、创建日志以及调用子调用和自毁等基本操作。
区块和交易的数据结构可以在 Environment 中找到。关于日志状态的更多信息可以在 JournaledState 文档中找到。
让我用通俗的语言解释这段内容:
EVM的核心组成
想象EVM是一台特殊的计算机,它主要由两个部分组成:
Context(上下文):就像计算机的内存和硬盘,存储运行时需要的所有数据Handler(处理器):就像计算机的CPU,负责执行各种操作的具体逻辑
Context的细分
Context分为两种类型:
-
EvmContext(EVM内部上下文):- 就像计算机的主要组件,包含:
- 数据库(存储数据)
- 环境配置(系统设置)
- 状态日志(操作记录)
- 预编译合约(内置程序)
- 就像计算机的主要组件,包含:
-
External(外部上下文):- 就像计算机的扩展接口
- 可以自由定制,添加额外功能
- 类似插件系统,可以加入新的功能或监控工具
Host特征
Host特征就像是EVM与外界通信的标准接口,包括:
- 账户操作(查询和修改账户信息)
- 存储访问(读写数据)
- 记录日志(保存操作记录)
- 合约调用(执行其他合约)
- 自毁操作(删除合约)
简单来说,就像计算机需要与外部设备(比如显示器、键盘)通信一样,EVM也需要通过Host特征定义的接口与外部世界进行交互。这确保了不同组件之间的标准化通信。
这整个设计让EVM既保持了核心功能的独立性,又具有很强的扩展性和灵活性。就像一台可以不断升级、添加新功能的计算机系统。
运行时
运行时由 Handler 中按预定义顺序调用的函数列表组成。它们按功能分为 Verification、PreExecution、Execution、PostExecution 和 Instruction 函数。验证函数与设置 Environment 数据的预验证相关。执行前/后函数扣除和奖励调用者受益人。而 Execution 函数处理初始调用和创建以及子调用。Instruction 函数是指令表的一部分,在 Interpreter 中用于执行操作码。
Evm 执行运行两个循环:
调用循环
第一个循环是调用循环,所有操作都从这里开始,它创建调用帧,处理子调用,返回输出并调用 Interpreter 循环来执行字节码指令。它由 ExecutionHandler 处理。
第一个循环实现了一个 Frames 栈。它负责处理子调用及其返回输出。在开始时,Evm 创建包含 Interpreter 的 Frame 并开始循环。
Interpreter 返回 InterpreterAction,可以是:
Return:此解释器完成运行。Frame从栈中弹出,其返回值被推送到父Frame栈中。SubCall/SubCreate:需要创建新的Frame并推送到栈中。创建新的Frame并推送到栈中,循环继续。当栈为空时,循环结束。
解释器循环
第二个循环是 Interpreter 循环,它由调用循环调用,循环遍历字节码操作码,并根据 InstructionTable 执行指令。它在 Interpreter crate 中实现。
要深入了解 Evm 逻辑,请查看 Handler 文档。
功能
Evm 的功能是启动执行,但设置要执行的内容是由 EvmBuilder 完成的。构建器的主要功能是:
preverify- 仅预验证交易信息。transact preverified- 是预验证后的下一步,执行交易。transact- 它同时调用预验证和执行交易。builder和modify函数 - 允许构建或修改Evm,更多相关信息可以在EvmBuilder文档中找到。builder是创建Evm的主要方式,modify允许你在不解散Evm的情况下修改其部分内容。into_context- 用于当我们想从Evm获取Context时。
EVM Builder
Builder 用于创建或修改 EVM 并应用不同的处理程序。它允许设置外部上下文和注册处理程序的自定义逻辑。
revm Evm 由 Context 和 Handler 组成。Context 进一步分为 EvmContext(包含通用 Database)和 External context(无约束的通用类型)。关于内部结构的更多信息请阅读 evm 文档。
Builder 将通用 Database、External context 和 Spec 之间的依赖关系关联起来。它允许添加在这些通用类型上实现逻辑的处理程序注册器。由于它们是相互关联的,设置 Database 或 ExternalContext 会重置处理程序注册器,因此引入了构建器阶段来缓解这些误用。
使用 EvmBuilder 的简单示例:
use crate::evm::Evm;
// 用默认值构建 Evm
let mut evm = Evm::builder().build();
let output = evm.transact();
Builder 阶段
有两个构建器阶段用于缓解构建器的潜在误用:
- SetGenericStage: 初始阶段,允许设置数据库和外部上下文。
- HandlerStage: 允许设置处理程序注册器,但在设置新的通用类型时会明确声明,因为这会使处理程序注册器失效。
一个阶段的函数只是另一个阶段函数的重命名,这样做是为了让用户更清楚底层函数的功能。例如,在 SettingDbStage 中我们有 with_db 函数,而在 HandlerStage 中我们有 reset_handler_with_db,它们都设置数据库,但后者还会重置处理程序。两个阶段都有一些共同的函数,如 build。
Builder 命名约定
在两个阶段中都有:
- build: 创建 Evm。
- spec_id: 创建新的主网处理程序并重新应用所有处理程序注册器。
- modify_*: 用于修改数据库、外部上下文或环境。
- clear_*: 允许为环境设置默认值。
- append_handler_register_*: 用于推送处理程序注册器。这会将构建器转换到 HandlerStage。
在 SetGenericStage 中:
- with_*: 用于设置通用类型。
在 HandlerStage 中:
- reset_handler_with_*: 用于更改某些通用类型,这将重置处理程序注册器。这会将构建器转换到 SetGenericStage。
创建和修改 Evm
Evm 实现了一些函数,使用户甚至不需要知道 EvmBuilder 的存在就能使用它。最明显的一个是 Evm::builder(),它用默认值创建一个新的构建器。
此外,一个非常重要的函数是 evm.modify(),它允许修改 Evm。它返回一个构建器,允许用户修改 Evm。
示例
使用检查器创建 Evm:
use crate::{
db::EmptyDB, Context, EvmContext, inspector::inspector_handle_register,
inspectors::NoOpInspector, Evm,
};
// 创建 evm
let evm = Evm::builder()
.with_db(EmptyDB::default())
.with_external_context(NoOpInspector)
// 注册器将修改 Handler 并调用 NoOpInspector
.append_handler_register(inspector_handle_register)
// .with_db(..) 不能编译,因为我们已经锁定了构建器泛型,
// 替代函数是 reset_handler_with_db(..)
.build();
// 执行 evm
let output = evm.transact();
// 提取 evm 上下文
let Context {
external,
evm: EvmContext { db, .. },
} = evm.into_context();
修改已构建的 evm 的规范 ID 和环境:
use crate::{Evm,SpecId::BERLIN};
// 创建默认 evm
let evm = Evm::builder().build();
// 修改 evm 规范
let evm = evm.modify().with_spec_id(BERLIN).build();
// 上面的快捷方式
let mut evm = evm.modify_spec_id(BERLIN);
// 执行 evm
let output1 = evm.transact();
// 修改 tx env 的示例
let mut evm = evm.modify().modify_tx_env(|env| env.gas_price = 0.into()).build();
// 使用修改后的 tx env 执行 evm
let output2 = evm.transact();
向 Evm 添加自定义预编译合约的示例:
use super::SpecId;
use crate::{
db::EmptyDB,
inspector::inspector_handle_register,
inspectors::NoOpInspector,
primitives::{Address, Bytes, ContextStatefulPrecompile, ContextPrecompile, PrecompileResult},
Context, Evm, EvmContext,
};
use std::sync::Arc;
struct CustomPrecompile;
impl ContextStatefulPrecompile<EvmContext<EmptyDB>, ()> for CustomPrecompile {
fn call(
&self,
_input: &Bytes,
_gas_limit: u64,
_context: &mut EvmContext<EmptyDB>,
_extcontext: &mut (),
) -> PrecompileResult {
Ok((10, Bytes::new()))
}
}
fn main() {
let mut evm = Evm::builder()
.with_empty_db()
.with_spec_id(SpecId::HOMESTEAD)
.append_handler_register(|handler| {
let precompiles = handler.pre_execution.load_precompiles();
handler.pre_execution.load_precompiles = Arc::new(move || {
let mut precompiles = precompiles.clone();
precompiles.extend([(
Address::ZERO,
ContextPrecompile::ContextStateful(Arc::new(CustomPrecompile)),
)]);
precompiles
});
})
.build();
evm.transact().unwrap();
}
添加处理程序注册器
处理程序注册器是一些简单的函数,允许通过替换处理程序函数来修改 Handler 逻辑。它们用于向 evm 执行添加自定义逻辑,但由于它们可以自由修改 Handler 的任何形式,如果添加的处理程序覆盖相同的函数,可能会产生冲突。
添加新逻辑到 Handler 最常见的用例是 Inspector,它用于检查 evm 的执行。可以在 Inspector 文档中找到这方面的示例。
Handler (处理程序)
这是 Evm 的逻辑部分。它包含规范 ID、执行逻辑的函数列表,以及在构建时可以更改 Handler 行为的注册器列表。
函数可以分为五类,在代码中也是这样标记的:
- 验证函数: ValidationHandler
- 执行前函数: PreExecutionHandler
- 执行函数: ExecutionHandler
- 执行后函数: PostExecutionHandler
- 指令表: InstructionTable
Handle Registers (处理程序注册器)
这是一个用于修改处理程序函数的简单函数。它们的一个很棒的特性是可以在泛型外部类型上实现。例如,这允许在一个允许为任何实现该特征的类型添加钩子的特征上进行注册。该特征可以是 GetInspector 特征,所以任何实现都能够注册与检查器相关的函数。GetInspector 在每个 Inspector 上都有实现,并在 EvmBuilder 中用于更改默认主网 Handler 的行为。
处理程序注册器在 EvmBuilder 中设置。注册器的顺序很重要,因为它们按照注册的顺序调用。如果注册器覆盖之前的处理程序或只是包装它,这很重要,因为覆盖处理程序可能会破坏之前注册的处理程序的逻辑。
注册器非常强大,因为它们允许修改 Evm 的任何部分,再加上 External 上下文,它成为了一个强大的组合。一个简单的例子是为 Evm 注册新的预编译合约。
ValidationHandler (验证处理程序)
包含用于验证交易和区块数据的函数。它们在交易执行之前调用,以检查环境(Environment)数据是否有效。它们按以下顺序调用:
- validate_env: 验证环境中所有数据是否设置且有效,例如 gas_limit 是否小于区块 gas_limit。
- validate_initial_tx_gas: 计算执行交易所需的初始 gas,并检查是否小于交易 gas_limit。注意这不会接触 Database 或状态。
- validate_tx_against_state: 加载调用者账户并检查其信息。包括检查 nonce、是否有足够的余额支付最大 gas 消耗和转账金额。
PreExecutionHandler (执行前处理程序)
包含在执行前调用的函数。它们按以下顺序调用:
- load: 从 Database 加载访问列表和受益人。这里进行冷加载。
- load_precompiles: 获取给定规范 ID 的预编译合约。更多信息:precompile。
- apply_eip7702_auth_list: 将 EIP-7702 授权列表应用到账户。返回已创建账户的 gas 退款。
- deduct_caller: 从调用者扣除值以计算交易可以花费的最大 gas 量。这会从 Database 加载调用者账户。
ExecutionHandler (执行处理程序)
包含处理交易执行和调用帧栈的函数:
-
call: 在每个帧上调用。它创建新的调用帧或返回帧结果(帧结果仅在调用预编译时返回)。如果返回 FrameReturn,则下一个调用的函数是 insert_call_outcome。
-
call_return: 在调用帧从执行返回后调用。用于计算从帧返回的 gas 并创建 FrameResult,在 insert_call_outcome 中用于将结果应用到父帧。
-
insert_call_outcome: 将调用结果插入父帧。在创建的每个帧上调用,除了第一个帧。对于第一个帧,我们使用 last_frame_return。
-
create: 创建新的创建调用帧,创建新账户并执行输出新账户代码的字节码。
-
create_return: 此处理程序在每个帧执行后调用(除第一个外)。它将计算从帧返回的 gas 并将输出应用到父帧。
-
insert_create_outcome: 将调用结果插入虚拟机状态。
-
last_frame_return: 此处理程序在最后一个帧返回后调用。用于计算从第一个帧返回的 gas 并合并交易 gas 限制(第一个帧限制为 gas_limit - initial_gas)。
InstructionTable (指令表)
这是一个包含 256 个函数指针的列表,用于执行指令。它们有两种类型:第一种是更快的简单函数,第二种是 BoxedInstruction,它有小的性能损失但允许捕获数据。更多信息请查看 Interpreter 文档。
PostExecutionHandler (执行后处理程序)
是执行后调用的函数列表。它们按以下顺序调用:
-
refund: 为已创建的账户添加 EIP-7702 退款,并计算最终 gas 退款,最多可达已用 gas 的 1/5(London 硬分叉前为 1/2)。
-
reimburse_caller: 用交易执行期间未使用的 gas 补偿调用者。或需要退还的 gas 余额。
-
reward_beneficiary: 用交易支付的费用奖励受益人。
-
output: 返回状态更改和执行结果。
-
end: 在交易后调用。如果验证失败,end 处理程序将不会被调用。
-
clear: 清除日志状态和错误,总是为清理而调用。
Inspectors(检查器)
该模块包含多个检查器,可用于通过 revm 库执行和监控以太坊虚拟机(EVM)上的交易。
概述
本模块中有几个内置的检查器:
NoOpInspector: 一个不执行任何操作的基本检查器,可在不需要监控交易时使用。GasInspector: 监控交易的 gas 使用情况。CustomPrintTracer: 在 EVM 执行期间跟踪并打印自定义消息。仅在启用std特性时可用。TracerEip3155: 这是一个符合 EIP-3155 标准的检查器,用于跟踪以太坊交易。它用于生成交易执行的详细跟踪数据,这对于调试、分析或构建需要理解以太坊交易内部工作原理的工具很有用。仅在同时启用std和serde-json特性时可用。
Inspector 特征
Inspector 特征定义了在 EVM 执行的各个阶段调用的方法。你可以实现这个特征来创建自己的自定义检查器。
这些方法中的每一个都在交易执行的不同阶段被调用。它们可以用于监控、调试或修改 EVM 的执行。
例如,step 方法在解释器的每一步都会被调用,而 log 方法在发出日志时被调用。
你可以为实现了 Database 特征的自定义数据库类型 DB 实现这个特征。
使用方法
要使用检查器,你需要实现 Inspector 特征。对于每个方法,你可以决定在 EVM 执行的每个点要做什么。例如,要捕获所有 SELFDESTRUCT 操作,可以实现 selfdestruct 方法。
Inspector 特征中的所有方法都是可选实现的;如果你不需要特定功能,可以使用提供的默认实现。
状态实现
State 继承 Database 特征并实现了外部状态和存储的获取,以及 EVM 执行输出的各种功能。最值得注意的是在执行多个交易时缓存更改。
数据库抽象
你可以根据结构体所需的处理方式实现 Database、DatabaseRef 或 Database + DatabaseCommit 特征。
-
Database: 在其函数中有可变的self。如果你想修改你的缓存或在get调用时更新一些统计数据,这很有用。这个特征启用了preverify_transaction、transact_preverified、transact和inspect函数。 -
DatabaseRef: 引用对象。如果你只有状态的引用并且不想更新它上面的任何内容,这很有用。它启用了preverify_transaction、transact_preverified_ref、transact_ref和inspect_ref函数。 -
Database + DatabaseCommit: 允许直接提交交易的更改。它启用了transact_commit和inspect_commit函数。
Journaled State (已记录状态)
revm crate 的 journaled_state 模块提供了以太坊风格账户的状态管理实现。它支持各种操作,如账户的自我销毁、初始账户加载、账户状态修改和日志记录。它还包含几个重要的工具函数,如 is_precompile。
该模块围绕 JournaledState 结构构建,该结构封装了区块链的整个状态。JournaledState 使用内部状态表示(一个 HashMap)来跟踪所有账户。每个账户由 Account 结构表示,包括余额、nonce 和代码哈希等字段。对于状态改变操作,该模块在"日志"中跟踪所有更改,以便于撤销和提交到数据库。这个特性在处理交易失败或其他异常情况下的状态更改撤销时特别有用。该模块通过 Database 特征与数据库交互,该特征抽象了获取和存储数据的操作。这种设计允许使用可插拔的后端,可以使用不同的 Database 特征实现以各种方式持久化状态(例如,内存或基于磁盘的数据库)。
数据结构
-
JournaledState: 该结构表示区块链的整个状态,包括账户、它们相关的余额、nonce 和代码哈希。它维护所有状态更改的日志,允许轻松撤销和提交对数据库的更改。
-
Account: 该结构表示区块链上的单个账户。它包括账户的余额、nonce 和代码哈希。它还包括一个表示账户是否自我销毁的标志,以及一个表示账户存储的映射。
-
JournalEntry: 该结构表示 JournaledState 日志中的一个条目。每个条目描述一个改变状态的操作,如账户加载、账户销毁或存储更改。
方法
-
selfdestruct: 将账户标记为自我销毁,并将其余额转移到目标账户。如果目标账户不存在,则创建它。如果自我销毁账户和目标是同一个,余额将丢失。
-
initial_account_load: 在不加载代码的情况下从数据库加载账户的基本信息。它还会将指定的存储槽加载到内存中。
-
load_account: 将账户信息加载到内存中,并返回账户是冷访问还是热访问。
-
load_account_exist: 检查账户是否存在。返回账户是冷访问还是热访问,以及它是否存在。
-
load_code: 从数据库将账户的代码加载到内存中。
-
sload: 加载账户的指定存储值。返回该值以及存储是否是冷加载。
-
sstore: 更改账户中指定存储槽的值,并返回原始值、当前值、新值以及存储是否是冷加载。
-
log: 向日志添加一个日志条目。
-
is_precompile: 检查一个地址是否是预编译合约。
相关 EIP
JournaledState 模块的操作主要设计为符合几个以太坊改进提案(EIP)中定义的以太坊标准。具体来说:
EIP-161: 状态树清理
EIP-161 旨在通过删除空账户来优化以太坊的状态管理。该规范由 Gavin Wood 提出,并在以太坊主网区块号 2,675,000 的 Spurious Dragon 硬分叉中激活。EIP 主要关注四个变更:
-
账户创建:在创建账户期间(无论是通过交易还是 CREATE 操作),新账户的 nonce 在初始化代码执行之前增加一。对于大多数网络,起始值为 1,但对于具有非零默认起始 nonce 的测试网络可能会有所不同。
-
CALL 和 SELFDESTRUCT 收费:在 EIP-161 之前,如果目标账户不存在,CALL 和 SELFDESTRUCT 操作会收取 25,000 gas 费用。在 EIP-161 中,只有在操作转移了大于零的值且目标账户已死亡(不存在或为空)时才收取此费用。
-
空账户的存在:账户不能从不存在状态变为存在但为空的状态。如果一个操作导致账户为空,该账户保持不存在。
-
删除空账户:在交易结束时,任何参与了潜在状态改变操作且现在为空的账户都将被删除。
定义:
- empty(空):如果账户没有代码,且其 nonce 和余额都为零,则该账户被视为"空"。
- dead(死亡):如果账户不存在或为空,则该账户被视为"死亡"。
- touched(触及):当账户参与任何潜在的状态改变操作时,该账户被视为"已触及"。
这些规则影响了在 EIP-161 上下文中如何管理状态,这影响了 JournaledState 模块的功能。例如,initial_account_load 和 selfdestruct 等操作都需要考虑账户是否为空和/或死亡。
理由
EIP-161 背后的理由是通过摆脱不必要的数据来优化以太坊状态管理。在此更改之前,状态树可能会因空账户而变得膨胀。这种膨胀导致以太坊节点的存储需求增加和处理时间变慢。
通过删除这些空账户,可以减少状态树的大小,从而提高以太坊节点的性能。此外,关于 CALL 和 SELFDESTRUCT 操作的 gas 成本的变更为以太坊 gas 模型添加了新的层次,进一步优化了交易处理。
EIP-658: 在收据中嵌入交易状态码
这个 EIP 特别重要,因为它引入了一种明确判断交易是否成功的方法。在引入 EIP-658 之前,仅根据交易的 gas 消耗无法确定交易是否成功。这是因为在 EIP-140 引入 REVERT 操作码后,交易可能会失败而不消耗所有 gas。
EIP-658 用一个状态码替换了收据中的中间状态根字段,该状态码表示交易的顶层调用是否成功或失败。状态码 1 表示成功,0 表示失败。
理由
EIP-658 的主要动机是提供一种明确确定交易成功或失败的方法。在 EIP-658 之前,用户必须依靠检查交易是否消耗了所有 gas 来猜测它是否失败。然而,由于在 EIP-140 中引入了 REVERT 操作码,这种方法不再可靠。
此外,虽然完整节点可以重放交易来获取其返回状态,但快速节点只能对其轴心点之后的交易这样做,而轻节点根本无法这样做。这意味着没有 EIP-658,非完整节点很难可靠地确定交易的状态。
EIP-2929: 状态访问操作码的 gas 成本增加
EIP-2929 提议增加几个操作码在交易中首次使用时的 gas 成本。该 EIP 的创建是为了通过增加潜在攻击向量的成本来缓解潜在的 DDoS 攻击,并使以太坊的无状态见证大小更易于管理。
EIP-2929 还引入了两个集合 accessed_addresses 和 accessed_storage_keys,用于跟踪在交易中已访问的地址和存储槽。这减轻了在同一交易中对相同地址或存储槽重复操作的额外 gas 成本,因为对已访问的地址或存储槽的任何重复操作将消耗较少的 gas。
在这个 EIP 的上下文中,"cold"(冷)和"warm"(热)或"hot"(热)指的是地址或存储槽在交易执行期间是否被之前访问过。如果在交易中首次访问地址或存储槽,则称为"冷"访问。如果在同一交易中已经访问过,任何后续访问都称为"热"访问。
参数:
- EIP 定义了新参数,如 COLD_SLOAD_COST(2100 gas)用于"冷"存储读取
- COLD_ACCOUNT_ACCESS_COST(2600 gas)用于"冷"账户访问
- WARM_STORAGE_READ_COST(100 gas)用于"热"存储读取
主要更改:
-
存储读取更改:对于 SLOAD 操作,如果(address, storage_key)对尚未在 accessed_storage_keys 中,收取 COLD_SLOAD_COST gas 并将该对添加到 accessed_storage_keys。如果该对已在 accessed_storage_keys 中,收取 WARM_STORAGE_READ_COST gas。
-
账户访问更改:当地址是某些操作码的目标时(EXTCODESIZE, EXTCODECOPY, EXTCODEHASH, BALANCE, CALL, CALLCODE, DELEGATECALL, STATICCALL),如果目标不在 accessed_addresses 中,收取 COLD_ACCOUNT_ACCESS_COST gas,并将地址添加到 accessed_addresses。否则,收取 WARM_STORAGE_READ_COST gas。
-
SSTORE 更改:对于 SSTORE 操作,如果(address, storage_key)对不在 accessed_storage_keys 中,额外收取 COLD_SLOAD_COST gas,并将该对添加到 accessed_storage_keys。
-
SELFDESTRUCT 更改:如果 SELFDESTRUCT 的接收者不在 accessed_addresses 中,额外收取 COLD_ACCOUNT_ACCESS_COST,并将接收者添加到该集合。
这种方法允许以太坊在交易内部维护已访问账户和存储槽的记录,从而可以对重复操作收取较低的 gas 费用,从而降低此类操作的成本。
理由
-
安全性:之前这些操作码定价过低,容易受到 DoS 攻击,攻击者发送访问或调用大量账户的交易。通过增加 gas 成本,EIP 旨在缓解这些潜在的安全风险。
-
改进无状态见证大小:无状态以太坊客户端不维护区块链的完整状态,而是依赖区块"见证"(交易执行期间访问的所有账户、存储和合约代码的列表)来验证交易。这个 EIP 有助于减少这些见证的大小,从而使无状态以太坊更可行。