使用 Geth 剖析 EVM 实现 1 — 交易执行流程
- 原文链接:medium.com/@deliriusz...
- 译者:AI翻译官,校对:翻译小组
- 本文链接:learnblockchain.cn/article…
照片由 Shubham Dhage 提供, 版权属于 Unsplash
本文描述了 EVM,如果你对了解交易的其他流程感兴趣——从发送交易到交易执行,这篇文章非常出色:
如果你想了解文章的其他部分,这里是:
我最近听到有人在谈论 Solidity 中的“调用上下文”,所以我插了一句想要简要讨论一下,但我觉得这个话题需要更广泛的解释,因为并不是很多人知道它是如何运作的。当我开始写这个话题时,我意识到交易执行的话题并没有被讨论过,我将在这里描述它,以提高领域内的认知。
我不知道亲爱的读者你怎么样,但我讨厌阅读研究论文。它们过于复杂,引入了被称为“科学符号”的非人类书写方式,读起来很痛苦。所以,我不会深入讨论以太坊黄皮书,而是会深入探讨 go-ethereum —— 以 Go 语言实现的以太坊执行客户端。但是在我们开始之前,我想指出几个 Go(或 golang)中可能难以理解的重要概念,如果你之前没有接触过这门语言,这些概念是理解我即将描述的内容所必需的:
如果你来自任何主流编程语言(包括 Solidity),你应该熟悉 OOP。简而言之——你有类,这些都是拥有状态和行为的“真实世界对象”的模板,这些模板是核心。然后,你可以将它们组合到其他类中(has-a 关系),或者引入继承(is-a 关系)来提取出共同的状态和行为到共同的祖先中。在 Go 中与类最接近的东西是结构体和接口的组合:
你可以通过使结构体实现所有接口函数来模仿 Go 中的类。是的,我知道这有点令人困惑,因此我们来看一下它在代码中的工作原理。最好的示例可以在 Go by example — interface 部分找到:
type geometry interface { // 这是一个接口,函数正如你所想的那样工作
area() float64
perim() float64
}
// 在我们的示例中,我们将在 rect 和 circle 类型上实现此接口。
type rect struct {
width, height float64
}
type circle struct {
radius float64
}
type circle struct {
geometry // 这种奇怪的表示法意味着该结构体包含一切
// geometry 所做的事情,在这种情况下是两个函数
radius float64
}
// 要在 Go 中实现接口,我们只需要实现接口中的所有方法。在这里,我们在矩形上实现 geometry。
func (r rect) area() float64 { // 这就是你在 Go 中定义“类”的行为部分的方式
return r.width * r.height
}
func (r rect) perim() float64 {
return 2*r.width + 2*r.height
}
// 圆的实现。
// 请注意这里的“(c circle)”部分。在这种情况下,“c”被视为“this”或“self”
// 从其他编程语言中来看。你可以将此函数定义读作:
// “在结构类型 circle 上定义的函数 area 获取参数并返回 float64”
func (c circle) area() float64 {
// 在 Java/JS 中,它将是 math.Pi * this.radius * this.radius
return math.Pi * c.radius * c.radius
}
func (c circle) perim() float64 {
return 2 * math.Pi * c.radius
}
// 这是 Go 中的构造函数模式——没有内置的“构造函数”概念。
// * 和 & 不重要,可以将其视为更有效的方式
// 传递复杂类型,或者如果你真的想要深入了解,
// 可以搜索“golang 指针”
func NewCircle(_radius float64) *circle {
return &rect{radius: _radius}
}
// 如果一个变量具有接口类型,则我们可以调用该命名接口中的方法。以下是一个通用的测量函数,利用这一点来对任何几何形状进行操作。
func measure(g geometry) {
fmt.Println(g)
fmt.Println(g.area())
fmt.Println(g.perim())
}
func main() {
r := rect{width: 3, height: 4}
c := circle{radius: 5}
// 圆形和矩形结构类型都实现了几何接口,因此我们可以使用这些结构的实例作为测量的参数。
measure(r)
measure(c)
}
至于继承,嗯……根本没有。所以大多数时候通过组合多个结构体/接口来解决这个问题。
如果你想了解更多关于它的信息,这里是官方的 Go 常见问题解答:
所有主流语言都有模块/包的概念,可以导入到你的文件中。通常包/模块与特定文件一起使用,均匀地识别你从中导入特定代码的地方。如果你导入自己的代码,则需要提供特定导入文件的路径。这使得跟踪执行流程变得非常容易。但 Go 做法有所不同——Go 模块可以跨多个文件,且不需要任何名称关联。天哪,你甚至可以直接从 GitHub 仓库源代码导入模块。因此,如果你没有一个具有良好代码索引功能的 IDE(是的,我在看所有人,除了 Goland),在查看大型 Go 代码库时,你会对生活产生质疑。下面的代码片段展示了示例包布局:
// file src/dir1/f1.go
package fish
...
// file src/dir1/f2.go
package fish
import (
"math/big" // 标准库
"mycompany.com/cat" // 我的本地库 cat,来自 src/dir2/f1.go 。与导入位置无关
)
...
// file src/dir2/f1.go
package fish
import (
"github.com/ethereum/go-ethereum/common" // 这会从当前主分支获取 GH 中的代码 :-O
)
...
// file src/dir2/f1.go
package cat // 如你所见,dir2 包含多个模块,文件名与包之间没有连接关系
...
当然 Go 引入了错误的概念,但它的工作方式与其他语言不同。
再次,我将使用来自 Go by example 的修改代码片段:
// 按约定,错误是最后一个返回值,类型为 error,这是一个内置接口。
func f1(arg int) (int, error) {
if arg == 42 {
// errors.New 构造一个基本的错误值,带有给定错误消息。
return -1, errors.New("can't work with 42")
}
// 错误位置的 nil 值表示没有错误。
return arg + 3, nil
}
func main() {
// 这就是你应该在 Go 中处理错误的方式
if r, e := f1(42); e != nil {
fmt.Println("f1 failed:", e)
} else {
fmt.Println("f1 worked:", r)
}
}
正如你所看到的,错误只是实现了 error 接口,并作为函数的最后一个参数传递。然而,语言本身并不强制执行这一点,这被视为一种良好的实践。
4. Go 结构元素可以定义元数据
这并不特别针对 Go。TypeScript 称之为“装饰器”,Java 称之为“注解”。这只是为类型提供一些附加属性的方法,在某些情况下可能会很有用,主要是一些外部库允许几乎无缝集成。以下是我写的一段 Go 代码的示例,定义了在转换为 JSON 时结构元素应如何命名,以及它们在处理 Gorm 数据库库时具有哪些特殊的数据库属性:
type PurchaseOrder struct {
Id uint `json:"id" gorm:"primaryKey"`
UserId string `json:"userId"`
Product []Product `json:"product" gorm:"foreignKey:Id"`
Date time.Time `json:"date"`
}
5. Go 最近才引入了泛型…
…, 所以并不是很多代码库使用到了它。如果你不知道“泛型”是什么,它是定义在任意类型参数上的通用函数。这意味着你可以提取出相似类型上的公共代码模式,仅需编写一次。欲了解更多信息,请查看 Go by example。实际上,你将在 geth 代码中找不到泛型,但我想写到这个,以限制你在看到那里的代码重复时每秒的 WTF 数量,心中疑惑“他们为什么不在这里使用泛型呢?”。
https://commadot.com/wtf-per-minute/
顺便说一句,我想说 Go 的泛型优于大多数其他语言,让我想起了来自函数式编程语言,特别是 Haskell 的 代数数据类型。
6. Go 有自己版本的“finally”
其他语言有一个选项来指示他们希望在结束时发生某事,通常称为“finally”块。Go 使用 defer
关键字,它接受一个函数作为参数,承诺在包含它的块结束时执行:
func main() {
// 在使用 createFile 获取文件对象后,我们立即延迟关闭该文件的操作。此操作将在封闭函数(main)结束后执行,writeFile 完成后执行。
f := createFile("/tmp/defer.txt")
defer closeFile(f)
writeFile(f)
}
顺便说一句,如果你想尝试 Go,可以查看我的 github 仓库,其中包含简单的 Go Web2/Web3 后端实现。
涵盖了与 Go 相关的最重要主题后,我们可以进入实际的执行流程。我将简要介绍网络和交易传播,因为从执行的角度来看,这真的没什么意思。整个流程开始于用户发送签名的交易。在后台,通过 HTTP(S) 发送 JSON-RPC 调用。然后,交易被传播到其他以太坊节点,放入内存池,等待处理。
因为 EVM 在非常高的层面上“仅仅”是一个状态机,每个传入交易都会改变其状态,让我们开始查看状态变化处理,使用 go-ethereum v1.11.5 作为我们探索的基础。
提到状态变化,我无法跳过最重要的部分——数据库。它的主要客户端接口位于 core/state/statedb.go。它是对底层 LevelDB 的一个抽象,提供了运行你的 EVM 业务所需的所有功能:
type StateDB interface {
CreateAccount(common.Address)
SubBalance(common.Address, *big.Int)
AddBalance(common.Address, *big.Int)
GetBalance(common.Address) *big.Int
GetNonce(common.Address) uint64
SetNonce(common.Address, uint64)
GetCodeHash(common.Address) common.Hash
GetCode(common.Address) []byte
SetCode(common.Address, []byte)
GetCodeSize(common.Address) int
...
GetCommittedState(common.Address, common.Hash) common.Hash
GetState(common.Address, common.Hash) common.Hash
SetState(common.Address, common.Hash, common.Hash)
...
// Exist 报告给定账户是否存在于状态中。
// 值得注意的是,这也应对自杀账户返回 true。
Exist(common.Address) bool
// Empty 返回给定账户是否为空。
// Empty 的定义根据 EIP161(余额 = nonce = code = 0)。
Empty(common.Address) bool
...
RevertToSnapshot(int)
Snapshot() int
AddLog(*types.Log)
...
}
我跳过了一些提供的函数,这些函数对本文不太有用。请看看 RevertToSnapshot 和 Snapshot 函数。这两个函数在状态管理中承担了所有的繁重工作。我们将在本文的第二部分中详细讨论这一点,当我们处理调用上下文时。
执行流程的主要部分在 core/state_processor.go,负责处理块中的所有交易并返回收据和日志,在此过程中修改 StateDB。让我们看看它是如何定义的,然后再讨论一下:
func (p *StateProcessor) Process(block *types.Block, statedb *state.StateDB, cfg vm.Config) (types.Receipts, []*types.Log, uint64, error) {
var (
receipts types.Receipts
usedGas = new(uint64)
header = block.Header()
blockHash = block.Hash()
blockNumber = block.Number()
allLogs []*types.Log
gp = new(GasPool).AddGas(block.GasLimit())
)
...
vmenv := vm.NewEVM(blockContext, vm.TxContext{}, statedb, p.config, cfg)
// 迭代并处理单个交易
for i, tx := range block.Transactions() {
msg, err := TransactionToMessage(tx, types.MakeSigner(p.config, header.Number), header.BaseFee)
if err != nil {
return nil, nil, 0, fmt.Errorf("could not apply tx %d [%v]: %w", i, tx.Hash().Hex(), err)
}
statedb.SetTxContext(tx.Hash(), i)
receipt, err := applyTransaction(msg, p.config, gp, statedb, blockNumber, blockHash, tx, usedGas, vmenv)
if err != nil {
return nil, nil, 0, fmt.Errorf("could not apply tx %d [%v]: %w", i, tx.Hash().Hex(), err)
}
receipts = append(receipts, receipt)
allLogs = append(allLogs, receipt.Logs...)
}
...
// 完成区块,应用任何特定于共识引擎的额外内容(例如,区块奖励)
p.engine.Finalize(p.bc, header, statedb, block.Transactions(), block.Uncles(), withdrawals)
}
return receipts, allLogs, *usedGas, nil
}
func applyTransaction(msg *Message, config *params.ChainConfig, gp *GasPool, statedb *state.StateDB, blockNumber *big.Int, blockHash common.Hash, tx *types.Transaction, usedGas *uint64, evm *vm.EVM) (*types.Receipt, error) {
// 创建一个新的上下文以在 EVM 环境中使用。
txContext := NewEVMTxContext(msg)
evm.Reset(txContext, statedb)
// 将交易应用于当前状态(包含在 env 中)。
result, err := ApplyMessage(evm, msg, gp)
if err != nil {
return nil, err
}
// 用待处理的更改更新状态。
var root []byte
if config.IsByzantium(blockNumber) {
statedb.Finalise(true)
} else {
root = statedb.IntermediateRoot(config.IsEIP158(blockNumber)).Bytes()
}
*usedGas += result.UsedGas
// 为交易创建新的收据,存储中间根和使用的 gas
// 通过 tx。
receipt := &types.Receipt{Type: tx.Type(), PostState: root, CumulativeGasUsed: *usedGas}
if result.Failed() {
receipt.Status = types.ReceiptStatusFailed
} else {
receipt.Status = types.ReceiptStatusSuccessful
}
receipt.TxHash = tx.Hash()
receipt.GasUsed = result.UsedGas
// 如果交易创建了一个合约,将创建地址存储在收据中。
if msg.To == nil {
receipt.ContractAddress = crypto.CreateAddress(evm.TxContext.Origin, tx.Nonce())
}
// 设置收据日志并创建布隆过滤器。
receipt.Logs = statedb.GetLogs(tx.Hash(), blockNumber.Uint64(), blockHash)
receipt.Bloom = types.CreateBloom(types.Receipts{receipt})
receipt.BlockHash = blockHash
receipt.BlockNumber = blockNumber
receipt.TransactionIndex = uint(statedb.TxIndex())
return receipt, err
}
这段代码非常直接。首先,创建新的 EVM 实例,对于区块中的所有交易:
a) 将交易解码为 Message 结构
b) 在 StateDB 中给交易分配 ID
c) 重置 EVM 为当前交易上下文和 stateDB
d) 将消息应用于当前状态。这是通过 EVM 执行它,并返回结果。我们将在接下来的部分深入研究 ApplyMessage() 。
e) 准备交易收据并将其与日志一起附加
完成后,最终确定区块,应用任何共识引擎特定的附加内容。这是因为目前以太坊的执行和共识部分是解耦的,然而它们必须进行沟通以保持网络的功能。
现在,让我们深入研究位于 core/state_transition.go 的 ApplyMessage() 最后代码片段
// ApplyMessage 通过应用给定消息来计算新状态
// 在环境中的旧状态。
//
// ApplyMessage 返回任何 EVM 执行返回的字节(如果发生),
// 使用的 gas(包括 gas 退款)和失败时的错误。错误总是
// 表示核心错误,意思是消息在特定状态下总是会失败,
// 并且永远不会被接受到区块中。
func ApplyMessage(evm *vm.EVM, msg *Message, gp *GasPool) (*ExecutionResult, error) {
return NewStateTransition(evm, msg, gp).TransitionDb()
}
这里没有什么有趣的,我们只是创建新的 StateTransition 结构,以便调用 TransitionDb() 函数。实际上,这个函数的名称相当不幸,因为它没有传达它真正的作用。其代码注释 对此描述得很好:
TransitionDb 将通过应用当前消息来转换状态,并返回包含以下字段的 EVM 执行结果。
— 使用的 gas:总共使用的 gas(包括退款的 gas)
— 返回数据:来自 EVM 的返回数据
— 具体的执行错误:各种中止执行的 EVM 错误,例如 ErrOutOfGas,ErrExecutionReverted
然而,如果遇到任何共识问题,则直接返回错误,EVM 执行结果为 nil
让我们剖析这个函数。首先,它进行所有必要的检查,以确保消息应该被视为有效执行:
func (st *StateTransition) TransitionDb() (*ExecutionResult, error) {
// 首先检查该消息是否满足所有共识规则
// 以便能应用该消息。这些规则包括以下条款
//
// 1. 消息调用者的 nonce 正确
// 2. 调用者有足够的余额来支付交易费用(gaslimit * gasprice)
// 3. 当前区块中有足够数量的 gas
// 4. 购买的 gas 足以覆盖固有使用
// 5. 在计算固有 gas 时没有溢出
// 6. 调用者有足够的余额来覆盖**最上层**调用的资产转移
// 检查条款 1-3,如果一切正确则购买 gas
if err := st.preCheck(); err != nil {
return nil, err
}
如果所有检查都成功,则检查这是合约创建交易(未设置交易接收者),还是常规调用,并相应地调用 EVM 函数:
...
contractCreation = msg.To == nil
var (
ret []byte
vmerr error // vm 错误不会影响共识,因此不会分配给 err
)
if contractCreation {
ret, _, st.gasRemaining, vmerr = st.evm.Create(sender, msg.Data, st.gasRemaining, msg.Value)
} else {
// 为下一笔交易增加 nonce
st.state.SetNonce(msg.From, st.state.GetNonce(sender.Address())+1)
ret, st.gasRemaining, vmerr = st.evm.Call(sender, st.to(), msg.Data, st.gasRemaining, msg.Value)
}
最后是费用计算。有几个要素——首先,如果你清理状态,可能会获得 gas 退款。其次,计算适当的提示。第三,你可能根本不需要支付 gas。什么鬼?这种情况是可能的,但目前仅被像 FlashBots 这样的 MEV 服务提供商使用。在这种情况下,MEV 搜索者直接将以太支付给 coinbase 地址,从而跳过费用。为什么?因为如果消息回滚,你仍然需要支付到回滚点的费用,而 MEV 搜索者通常将数十个或数百个交易聚集到一个捆绑包中,失败这样的交易会给他们带来巨大的损失。此外,你可能会看到一些与以太坊硬分叉相关的规则。起初,你可能会认为这是开发者的疏忽,他们留出了死代码,但实际上这对于在历史区块上运行模拟是有用的。
if !rules.IsLondon {
// 在 EIP-3529 之前:退款上限为 gasUsed / 2
st.refundGas(params.RefundQuotient)
} else {
// 在 EIP-3529 之后:退款上限为 gasUsed / 5
st.refundGas(params.RefundQuotientEIP3529)
}
effectiveTip := msg.GasPrice
if rules.IsLondon {
effectiveTip = cmath.BigMin(msg.GasTipCap, new(big.Int).Sub(msg.GasFeeCap, st.evm.Context.BaseFee))
}
if st.evm.Config.NoBaseFee && msg.GasFeeCap.Sign() == 0 && msg.GasTipCap.Sign() == 0 {
// 当 NoBaseFee 被设置并且费用字段为 0 时跳过费用支付。
// 这可以避免在模拟调用时对 coinbase 应用负的 effectiveTip。
} else {
fee := new(big.Int).SetUint64(st.gasUsed())
fee.Mul(fee, effectiveTip)
st.state.AddBalance(st.evm.Context.Coinbase, fee)
}
作为旁注,我发现了一个函数 buyGas (),这表明 gas 并不是以某种野蛮的方式从你这里扣除——你是从一个验证者那里购买的,这是自由市场宝贝!
目前就这些。在 第二部分 中,我们将最终了解 core/vm/evm.go,并将看到你的字节码是如何运行的。
我希望你喜欢这篇文章并学到了新东西。如果你想深入研究 geth 探索,这里有一些额外的资料供你查阅(请注意这些可能已过时):
如果你想阅读我的更多内容,请在 Twitter 上关注我。如果你需要高质量的安全审核(即审计)你的智能合约、智能合约安全顾问或智能合约开发,请随时联系我!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!