WEB3DEV

Cover image for Dissecando a EVM usando a implementação do cliente go-ethereum Eth. Parte I - fluxo de execução de transações
Rafael Ojeda
Rafael Ojeda

Posted on

Dissecando a EVM usando a implementação do cliente go-ethereum Eth. Parte I - fluxo de execução de transações

Dissecando a EVM usando a implementação do cliente go-ethereum Eth. Parte I - fluxo de execução de transações

Image description

Foto de Shubham Dhage no Unsplash

Este artigo descreve a EVM “Ethereum Virtual Machine“ ( Máquina Virtual Ethereum ), se você estiver interessado em ler sobre o restante do processo - desde o envio de uma transação até a execução da transação, este artigo é excelente:

https://www.notonlyowner.com/learn/what-happens-when-you-send-one-dai

Se você quiser acessar outras partes deste artigo, aqui estão elas:

Recentemente, ouvi pessoas falando sobre "contexto de chamada" no Solidity, então entrei na discussão para falar brevemente sobre isso, mas acredito que esse tópico precisa de uma explicação mais ampla, pois poucas pessoas sabem como ele realmente funciona. Ao começar a escrever sobre o assunto, percebi que o tópico de execução de transações não é discutido em nenhum lugar.

Não sei quanto a você, caro leitor, mas eu odeio ler artigos de pesquisa. Eles são excessivamente complexos, apresentam rabiscos desumanos chamados de "notação científica" e são difíceis de ler. Portanto, não vou me debruçar sobre o yellow paper do Ethereum, mas sim me aprofundar no go-ethereum - implementação do cliente de execução do Ethereum na linguagem Go. Mas, antes de começarmos, gostaria de destacar várias ideias importantes da linguagem Go (ou golang), que podem ser difíceis de entender se você não tiver tido nenhum contato prévio com a linguagem, o que será necessário para entender completamente os conceitos que estou prestes a descrever:

  1. A linguagem Go é (mais ou menos) orientada a objetos

Se você vem de qualquer linguagem de programação convencional (incluindo Solidity), conhece a OOP “Object-Oriented Programming" (Programação Orientada a Objetos). Em resumo, você tem classes, que são modelos de "objetos do mundo real" com estado e comportamento, que estão no centro. Em seguida, você pode compô-las em outras classes (relação tem-u) ou introduzir a herança (relação é-um) para extrair o estado e o comportamento comuns em um ancestral comum. O que mais se aproxima das classes em Go são os structs e interfaces compostas:

  • struct - uma coleção tipada de campos - define o estado. Funciona exatamente como os structs no Solidity.
  • interface - coleções nomeadas de assinaturas de métodos - define o comportamento. Funciona exatamente como as interfaces no Solidity.

Você pode imitar uma classe em Go fazendo com que um struct implemente todas as funções de interface. Sim, eu sei que é um pouco confuso, então vamos ver como isso funciona no código. O melhor exemplo disso pode ser encontrado na seção Go by example ( Go por exemplo ) - interface:


type geometry interface { // é uma interface, funciona exatamente como você imagina

    area() float64

    perim() float64

}

// Para nosso exemplo, implementaremos essa interface nos tipos retângulo e círculo.

type rect struct {

    width, height float64

}

type circle struct {

    radius float64

}

type circle struct {

    geometry // essa notação estranha significa que essa estrutura contém tudo

             // o que a geometria faz, que, neste caso, são as duas funções

    radius float64

}

// Para implementar uma interface em Go, precisamos apenas implementar todos os métodos da interface. Aqui implementamos a geometria em retos.

func (r rect) area() float64 { // é assim que você define uma parte do comportamento de uma "classe" em Go

    return r.width * r.height

}

func (r rect) perim() float64 {

    return 2*r.width + 2*r.height

}

// A implementação para círculos.

// atenção à parte "(c circle)" aqui. "c", nesse caso, é tratado como "this" ou "self"

// em outras linguagens de programação. Portanto, você pode ler essa definição de função como:

// "function area defined on struct type circle takes on parameters and returns float64"

func (c circle) area() float64 {

    // em Java/JS seria math.Pi * this.radius * this.radius

    return math.Pi * c.radius * c.radius

}

func (c circle) perim() float64 {

    return 2 * math.Pi * c.radius

}

// esse é o padrão de construtor em Go - não existe o conceito de "construtor" embutido.

// * e & não são importantes para entender, trate-os como uma forma mais eficiente

// de passar tipos complexos, ou se você realmente quiser se aprofundar no assunto,

// procure por "golang pointer" (ponteiro golang)

func NewCircle(_radius float64) *circle {

  return &rect{radius: _radius}

}

// Se uma variável tiver um tipo de interface, poderemos chamar métodos que estejam na interface nomeada. Aqui está uma função de medida genérica que tira proveito disso para funcionar em qualquer geometria.

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}

// Os tipos de estrutura circle e rect implementam a interface de geometria, portanto, podemos usar instâncias dessas estruturas como argumentos para medir.

    measure(r)

    measure(c)

}

Enter fullscreen mode Exit fullscreen mode

E quando se trata de herança, bem... Não há nenhuma. Portanto, na maioria das vezes, isso é contornado pela composição de várias structs/interfaces.

Se você quiser saber mais sobre isso, aqui está o FAQ oficial do Go sobre isso:

https://go.dev/doc/faq#Is_Go_an_object-oriented_language

  1. Os módulos em Go são irritantes

Todas as principais linguagens têm o conceito de módulos/pacotes que podem ser importados para o seu arquivo. Normalmente, o pacote/módulo é usado em conjunto com um arquivo específico, pois identifica uniformemente o local de onde você importa o código específico. Se você importar seu próprio código, colocará um caminho para o arquivo específico importado. Isso facilita muito o rastreamento do fluxo de execução. Mas a linguagem Go faz isso de forma diferente - os módulos Go podem se estender por vários arquivos e não precisam ter nenhuma relação de nome. Além disso, você pode até importar um módulo diretamente do código-fonte do repositório do GitHub. Portanto, se você não tiver um IDE "Integrated Development Environment" (Ambiente de Desenvolvimento Integrado) com bons recursos de indexação de código (sim, estou olhando para todos vocês, exceto para o Goland), você questionará o que está sua vida ao olhar para enormes bases de código Go. O trecho de código abaixo mostra um exemplo de layout de pacote:


// arquivo src/dir1/f1.go

pacote fish

...

// arquivo src/dir1/f2.go

pacote fish

import (

 "math/big" // biblioteca padrão

 "mycompany.com/cat" // minha biblioteca local cat from src/dir2/f1.go . Nenhuma relação com o local de importação

)

...

// arquivo src/dir2/f1.go

pacote fish

import (

 "github.com/ethereum/go-ethereum/common" // isso pega o código do ramo mestre atual do GH :-O

)

...

// arquivo src/dir2/f1.go

package cat // como você pode ver, dir2 contém vários módulos, sem conexão entre o nome do arquivo e o pacote

...

Enter fullscreen mode Exit fullscreen mode
  1. A linguagem Go não tem a noção de "lançar" erros

É claro que a linguagem Go introduz o conceito de erro, mas ele funciona de forma diferente do que em outras linguagens.

Novamente, usarei um trecho de código modificado da linguagem Go by example:


// Por convenção, os erros são o último valor de retorno e têm o tipo error, uma interface incorporada.

func f1(arg int) (int, error) {

    if arg == 42 {

// errors.New constrói um valor de erro básico com a mensagem de erro fornecida.

        return -1, errors.New("não é possível trabalhar com 42")

    }

// Um valor nil na posição de erro indica que não houve erro.

    return arg + 3, nil

}

func main() {

    // é assim que você deve tratar os erros em Go

    if r, e := f1(42); e != nil {

        fmt.Println("f1 falhou:", e)

    } else {

        fmt.Println("f1 funcionou:", r)

    }

} 

Enter fullscreen mode Exit fullscreen mode

Como você pode ver, os erros apenas implementam a interface de erro e são passados como o último parâmetro da função. Entretanto, a linguagem em si não impõe isso, que é tratado como uma boa prática.

  1. Os elementos de struct em Go podem definir metadados

Isso não é realmente específico da linguagem Go. O TypeScript os chama de "decorators" (decoradores) e o Java os chama de "annotations" (anotações). Essa é apenas uma maneira de dar alguns atributos adicionais a um tipo, que podem ser úteis em determinadas situações, principalmente algumas bibliotecas externas que permitem uma integração quase sem esforço. Aqui está um exemplo de um código Go que escrevi há algum tempo, definindo como os elementos struct devem ser nomeados quando convertidos em JSON e quais atributos especiais de banco de dados eles têm ao lidar com a biblioteca Gorm DB:


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"`

} 

Enter fullscreen mode Exit fullscreen mode
  1. O Go introduziu os genéricos apenas recentemente...

..., portanto, poucas bases de código migraram para ele. Caso você não saiba o que são "genéricos", trata-se de funções genéricas definidas sobre parâmetros de tipos arbitrários. Isso significa que você pode extrair padrões de código comuns de tipos semelhantes, escrevendo-os apenas uma vez. Para obter mais informações, consulte Go by example. Na verdade, você não encontrará genéricos no código geth, mas eu quis escrever sobre isso para limitar a quantidade de "WTFs" por segundo que você tem ao ver o código duplicado lá, perguntando a si mesmo "por que eles não usaram genéricos aqui?".

Image description

https://commadot.com/wtf-per-minute/

Como observação adicional, acrescentarei que os genéricos do Go são excelentes para a maioria das outras linguagens, lembrando-me dos tipos de dados algébricos das linguagens de programação funcional, especificamente Haskell.

  1. A linguagem Go tem sua própria versão de "finally"

Outras linguagens têm uma opção para indicar que querem que algo aconteça no final, geralmente chamada de bloco "finally". A linguagem Go usa a palavra-chave defer, que aceita uma função como parâmetro e promete executá-la no final do bloco em que está contida:


func main() {

    // Imediatamente após obter um objeto de arquivo com createFile, adiamos o fechamento desse arquivo com closeFile. Isso será executado no final da função anexa (main), após a conclusão do writeFile.

    f := createFile("/tmp/defer.txt")

    defer closeFile(f)

    writeFile(f)

} 

Enter fullscreen mode Exit fullscreen mode

Aliás, se quiser experimentar o Go, confira meu repositório do github que contém uma implementação simples do backend Go Web2/Web3.

Com os tópicos mais importantes relacionados a Go definidos, podemos passar para o fluxo de execução real. Vou abordar brevemente a rede e a propagação de transações, pois isso não é realmente interessante do ponto de vista da execução. Todo o fluxo começa com o envio de uma transação assinada pelo usuário. No âmbito da estrutura, é enviada uma chamada JSON-RPC por HTTP(S). Em seguida, a transação é propagada para outros nós da Ethereum e colocada no mempool esperando para ser processada.

Como a EVM é, em um nível muito alto, "apenas" uma máquina de estado, mudando seu estado a cada transação recebida, vamos começar analisando o processamento de mudança de estado usando o go-ethereum v1.11.5 como base para nossa exploração.

Ao mencionar a mudança de estado, não poderia deixar de lado a parte mais importante: o banco de dados. A interface principal do cliente para ele está localizada em core/state/statedb.go. É uma abstração sobre o LevelDB subjacente, fornecendo todos os recursos de que você precisa para executar seu negócio na EVM:


tipo 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 informa se a conta fornecida existe no estado.

 // Notavelmente, isso também deve retornar true para contas suicidas.

 Exist(common.Address) bool

 // Empty retorna se a conta fornecida está vazia. Vazia

 // é definido de acordo com a EIP161 (balance = nonce = code = 0).

 Empty(common.Address) bool

 ...

 RevertToSnapshot(int)

 Snapshot() int

 AddLog(*types.Log)

 ...

} 

Enter fullscreen mode Exit fullscreen mode

Ignorei algumas das funções oferecidas, que não são úteis para este artigo. Dê uma olhada nas funções RevertToSnapshot e Snapshot. Essas duas fazem todo o trabalho pesado relativo ao gerenciamento de estado. Discutiremos isso em detalhes na Parte II deste artigo, quando trataremos do contexto de chamada.

A parte principal do fluxo de execução é core/state_processor.go, responsável por processar todas as transações em um bloco e retornar os recibos e os registros, modificando o StateDB no processo. Vamos ver como ele é definido e depois discuti-lo:


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)

 // Iterar e processar as transações individuais

 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("não foi possível aplicar 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)

  se err != nil {

   return nil, nil, 0, fmt.Errorf("não foi possível aplicar tx %d [%v]: %w", i, tx.Hash().Hex(), err)

  }

  receipts = append(receipts, receipt)

  allLogs = append(allLogs, receipt.Logs...)

 }

 ...

 // Finalizar o bloco, aplicando quaisquer extras específicos do mecanismo de consenso (por exemplo, recompensas do bloco)

 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) {

 // Criar um novo contexto a ser usado no ambiente EVM.

 txContext := NewEVMTxContext(msg)

 evm.Reset(txContext, statedb)

 // Aplicar a transação ao estado atual (incluído no ambiente).

 result, err := ApplyMessage(evm, msg, gp)

 se err != nil {

  return nil, err

 }

 // Atualizar o estado com as alterações pendentes.

 var root []byte

 if config.IsByzantium(blockNumber) {

  statedb.Finalise(true)

 } else {

  root = statedb.IntermediateRoot(config.IsEIP158(blockNumber)).Bytes()

 }

 *usedGas += result.UsedGas

 // Criar um novo recibo para a transação, armazenando a raiz intermediária e o gás usado

 // usado pelo 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

 // Se a transação criou um contrato, armazene o endereço de criação no recibo.

 se msg.To == nil {

  receipt.ContractAddress = crypto.CreateAddress(evm.TxContext.Origin, tx.Nonce())

 }

// Defina os registros de recibo e crie o filtro de florescimento.

 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

}

Enter fullscreen mode Exit fullscreen mode

O código é bastante simples. Primeiro, crie uma nova instância da EVM e para todas as transações no bloco:

a) decodificar a transação em uma estrutura de mensagem

b) atribuir ID à transação no bloco no StateDB

c) redefinir a EVM para o contexto da transação atual e o stateDB

c) aplicar a mensagem ao estado atual. Ou seja, executá-la usando a EVM e retornar o resultado. Vamos nos aprofundar em ApplyMessage() a seguir.

d) preparar o recibo da transação e anexá-lo junto com os registros

Quando terminar, finalize o bloco, aplicando quaisquer extras específicos do mecanismo de consenso. Isso se deve ao fato de que, atualmente, as partes de execução e consenso da Ethereum são desacopladas, mas precisam se comunicar para manter a rede funcional.

Agora, vamos nos aprofundar no último trecho do código ApplyMessage() localizado em core/state_transition.go


// ApplyMessage calcula o novo estado aplicando a mensagem fornecida

// contra o estado antigo dentro do ambiente.

//

// ApplyMessage retorna os bytes retornados por qualquer execução de EVM (se tiver ocorrido),

// o gás usado (que inclui reembolsos de gás) e um erro caso tenha falhado. Um erro sempre

// indica um erro de núcleo, o que significa que a mensagem sempre falhará para esse

// estado específico e nunca seria aceita em um bloco.

func ApplyMessage(evm *vm.EVM, msg *Message, gp *GasPool) (*ExecutionResult, error) {

 return NewStateTransition(evm, msg, gp).TransitionDb()

}

Enter fullscreen mode Exit fullscreen mode

Nada de interessante aqui, estamos apenas criando uma nova estrutura StateTransition para chamar a função TransitionDb() nela. Na verdade, o nome dessa função é bastante infeliz, pois não transmite o que ela realmente faz. O comentário de código faz um bom trabalho ao descrevê-la:

O TransitionDb fará a transição do estado aplicando a mensagem atual e retornando o resultado da execução da evm com os seguintes campos.

- used gas (gás usado): total de gás usado (incluindo o gás que está sendo reembolsado)

- returndata (dados de retorno): os dados retornados da evm

- concrete execution error (erro de execução concreto): vários erros de EVM que abortam a execução, por exemplo, ErrOutOfGas, ErrExecutionReverted

No entanto, se for encontrado algum problema de consenso, retorne o erro diretamente com nil evm execution result

Vamos dissecar essa função. Primeiro, ela faz todas as verificações necessárias para garantir que a mensagem seja considerada válida para execução:


func (st *StateTransition) TransitionDb() (*ExecutionResult, error) {

 // Primeiro, verifique se essa mensagem satisfaz todas as regras de consenso antes de

 // aplicar a mensagem. As regras incluem as seguintes cláusulas

 //

 // 1. o nonce do chamador da mensagem está correto

 // 2. o chamador tem saldo suficiente para cobrir a taxa de transação (gaslimit * gasprice)

 // 3. a quantidade de gás necessária está disponível no bloco

// 4. o gás comprado é suficiente para cobrir o uso interno

// 5. não há estouro no cálculo do gás interno

 // 6. o chamador tem saldo suficiente para cobrir a transferência de ativos para a chamada **mais alta**

 // Verifique as cláusulas 1-3, compre gás se tudo estiver correto

 if err := st.preCheck(); err != nil {

  return nil, err

 }

Enter fullscreen mode Exit fullscreen mode

Se todas as verificações forem bem-sucedidas, verificaremos se essa é uma transação de criação de contrato (sem o destinatário da transação definido) ou uma chamada regular e invocaremos as funções da EVM de acordo:


...

 contractCreation = msg.To == nil

 var (

  ret []byte

  erro de vmerr // erros de vm não afetam o consenso e, portanto, não são atribuídos a err

 )

 se contractCreation {

  ret, _, st.gasRemaining, vmerr = st.evm.Create(sender, msg.Data, st.gasRemaining, msg.Value)

 } else {

  // Incrementar o nonce para a próxima transação

  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)

 } 

Enter fullscreen mode Exit fullscreen mode

E, finalmente, os cálculos das taxas. Há algumas partes móveis - primeiro, você pode receber reembolso de gás se passar pelo estado. Segundo, a gorjeta adequada é calculada. Terceiro, você pode não pagar nada pelo gás. O QUE ESTÁ ACONTECENDO? Esse caminho é possível, mas atualmente só é usado por provedores de serviços MEV "Miner Extractable Value" (Valor Extratável pelo Minerador) como a FlashBots. Nesse caso, o pesquisador de MEV paga o ether diretamente no endereço da coinbase, ignorando as taxas. Por quê? Porque, se uma mensagem for revertida, você ainda terá que pagar as taxas até o ponto de reversão, e os pesquisadores de MEV geralmente reúnem dezenas ou centenas de transações em um único pacote, e a falha de uma transação desse tipo acarretaria grandes perdas para eles. Além disso, você pode ver que há algumas regras relativas aos hard forks da Ethereum. A princípio, você pode pensar que isso é desleixo do desenvolvedor, que deixou um código morto aqui, mas na verdade é útil para executar simulações em blocos históricos.


if !rules.IsLondon {

  // Antes do EIP-3529: os reembolsos eram limitados a gasUsed / 2

  st.refundGas(params.RefundQuotient)

 } else {

  // Após a EIP-3529: os reembolsos são limitados a 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 {

  // Pular o pagamento da taxa quando NoBaseFee estiver definido e os campos de taxa

  // Isso evita que um effectiveTip negativo seja aplicado à

  // à base de moedas ao simular chamadas.

 } else {

  fee := new(big.Int).SetUint64(st.gasUsed())

  fee.Mul(fee, effectiveTip)

  st.state.AddBalance(st.evm.Context.Coinbase, fee)

 }

Enter fullscreen mode Exit fullscreen mode

Como observação lateral, encontrei uma função buyGas (), que indica que o gás não é simplesmente deduzido de você de alguma forma absurda - você o compra de um validador, é um mercado livre!

Isso é tudo por enquanto. Na Parte II, finalmente conheceremos o core/vm/evm.go e veremos como seu bytecode é executado.

Espero que você tenha gostado e aprendido algo novo aqui. Se você quiser se aprofundar na exploração do geth, aqui estão alguns materiais adicionais para conferir (lembre-se de que eles podem estar desatualizados):

Se quiser ler mais sobre meus artigos, siga-me no Twitter. Se precisar de uma análise de segurança de alta qualidade (também conhecida como auditoria) de seus contratos inteligentes, de um consultor de segurança de contratos inteligentes ou de um desenvolvedor de contratos inteligentes, sinta-se à vontade para entrar em contato comigo!

Artigo escrito por deliriusz.eth e traduzido para o português por Rafael Ojeda.

Aqui você encontra o artigo original.

Top comments (0)