使用 Geth 剖析 EVM 实现 #1 — 交易执行流程

使用 Geth 剖析 EVM 实现 1 — 交易执行流程

照片由 Shubham Dhage 提供, 版权属于 Unsplash

简介

本文描述了 EVM,如果你对了解交易的其他流程感兴趣——从发送交易到交易执行,这篇文章非常出色:

深度解析:在发送 1 个DAI 时发生了什么

如果你想了解文章的其他部分,这里是:

我最近听到有人在谈论 Solidity 中的“调用上下文”,所以我插了一句想要简要讨论一下,但我觉得这个话题需要更广泛的解释,因为并不是很多人知道它是如何运作的。当我开始写这个话题时,我意识到交易执行的话题并没有被讨论过,我将在这里描述它,以提高领域内的认知。

我不知道亲爱的读者你怎么样,但我讨厌阅读研究论文。它们过于复杂,引入了被称为“科学符号”的非人类书写方式,读起来很痛苦。所以,我不会深入讨论以太坊黄皮书,而是会深入探讨 go-ethereum —— 以 Go 语言实现的以太坊执行客户端。但是在我们开始之前,我想指出几个 Go(或 golang)中可能难以理解的重要概念,如果你之前没有接触过这门语言,这些概念是理解我即将描述的内容所必需的:

Go 难以理解的概念

  1. Go 是(有点)面向对象的

如果你来自任何主流编程语言(包括 Solidity),你应该熟悉 OOP。简而言之——你有类,这些都是拥有状态和行为的“真实世界对象”的模板,这些模板是核心。然后,你可以将它们组合到其他类中(has-a 关系),或者引入继承(is-a 关系)来提取出共同的状态和行为到共同的祖先中。在 Go 中与类最接近的东西是结构体和接口的组合:

  • struct — 字段的类型集合 — 定义状态。 工作方式与 Solidity 中的结构体相同。
  • interface — 方法签名的命名集合 — 定义行为。 工作方式与 Solidity 中的接口相同。

你可以通过使结构体实现所有接口函数来模仿 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 常见问题解答:

常见问题解答(FAQ) — Go 编程语言

  1. 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 包含多个模块,文件名与包之间没有连接关系  
...
  1. Go 并没有“抛出”错误的概念

当然 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)  
 ...  
}

我跳过了一些提供的函数,这些函数对本文不太有用。请看看 RevertToSnapshotSnapshot 函数。这两个函数在状态管理中承担了所有的繁重工作。我们将在本文的第二部分中详细讨论这一点,当我们处理调用上下文时。

执行流程的主要部分在 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.goApplyMessage() 最后代码片段

// 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 上关注我。如果你需要高质量的安全审核(即审计)你的智能合约、智能合约安全顾问或智能合约开发,请随时联系我!

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
deliriusz.eth
deliriusz.eth
江湖只有他的大名,没有他的介绍。