WEB3DEV

Cover image for Dissecando a EVM usando a implementação do cliente go-ethereum Eth. Parte II - EVM
Rafael Ojeda
Rafael Ojeda

Posted on

Dissecando a EVM usando a implementação do cliente go-ethereum Eth. Parte II - EVM

Dissecando a EVM usando a implementação do cliente go-ethereum Eth. Parte II - EVM

Image description

Foto de Shubham Dhage no Unsplash

Na Parte I, discutimos o fluxo de execução de transações, agora vamos passar para o verdadeiro herói do Ethereum - EVM. Quase tudo que nos interessa está localizado na pasta core/vm. Vamos começar com a evm.go real e ver como algumas estruturas importantes da EVM são definidas:


type BlockContext struct {

 // CanTransfer retorna se a conta contém

// ether suficiente para transferir o valor

 CanTransfer CanTransferFunc

// Transfer transfere ether de uma conta para outra

 Transfer TransferFunc

 // GetHash retorna o hash correspondente a n

 GetHash GetHashFunc

 // Informações do bloco

 Coinbase common.Address // Fornece informações para COINBASE

 GasLimit uint64 // Fornece informações sobre GASLIMIT

 BlockNumber *big.Int // Fornece informações para NUMBER

 Time uint64 // Fornece informações para TIME

 Difficulty *big.Int // Fornece informações para DIFFICULTY

 BaseFee *big.Int // Fornece informações para BASEFEE

 Random *common.Hash // Fornece informações para PREVRANDAO

}

// TxContext fornece à EVM informações sobre uma transação.

// Todos os campos podem mudar entre as transações.

type TxContext struct {

 // Informações sobre a mensagem

 Origin common.Address // Fornece informações sobre a ORIGEM

 GasPrice *big.Int // Fornece informações sobre GASPRICE

}

type EVM struct {

// O contexto fornece informações auxiliares relacionadas à blockchain

Contexto BlockContext

 TxContext

// O StateDB dá acesso ao estado básico

 StateDB StateDB

 // Depth é a pilha de chamadas atual

 depth int

 // chainConfig contém informações sobre a cadeia atual

 chainConfig *params.ChainConfig

 // Regras da cadeia contém as regras da cadeia para a época atual

 chainRules params.Rules

 // opções de configuração da máquina virtual usadas para inicializar o

 // evm.

 Config Config

 // global (para esse contexto) da máquina virtual ethereum

 // usado em toda a execução do tx.

 interpretador *EVMInterpretador

 // abort é usado para abortar as operações de chamada da EVM

 // OBSERVAÇÃO: deve ser definido atomicamente

 abort int32

 // callGasTemp mantém o gás disponível para a chamada atual. Isso é necessário porque o

 // gás disponível é calculado em gasCall* de acordo com a regra 63/64 e depois

 // aplicada posteriormente em opCall*.

callGasTemp uint64

}

Enter fullscreen mode Exit fullscreen mode

A partir disso, você pode deduzir o que é o "contexto" na Ethereum. Seja contexto de transação, contexto de bloco, contexto de chamada, todos eles são apenas metadados que definem algumas informações úteis para a execução atual em diferentes níveis de abstração (chamada/rastreamento/etc.).

Criação de contrato

Se você se lembra da Parte I, o caminho do código para a criação do contrato é ligeiramente diferente da execução da chamada. Mas, na verdade, ambos os caminhos se encontram em um ponto comum - a execução do bytecode -, isso porque um construtor de contrato inteligente é, na verdade, um trecho de código executável que, no final, retorna dois elementos: o deslocamento e o comprimento do código a ser implantado, e esses valores são usados para colocar bytes específicos desse intervalo no endereço do contrato inteligente recém-criado. Portanto, isso significa que você pode criar um contrato inteligente de forma processual, salvá-lo na memória e, em seguida, retornar seu local na memória. Eu não vi isso na natureza, mas esse poderia ser um uso bastante interessante: criar código de contrato inteligente em tempo real, com base no estado da cadeia.

Após essa introdução sobre a criação de código, vamos ao código para ver como ele é implementado:


func (evm *EVM) Create(caller ContractRef, code []byte, gas uint64, value *big.Int) (ret []byte, contractAddr common.Address, leftOverGas uint64, err error) {

 contractAddr = crypto.CreateAddress(caller.Address(), evm.StateDB.GetNonce(caller.Address()))

 return evm.create(caller, &codeAndHash{code: code}, gas, value, contractAddr, CREATE)

}

func (evm *EVM) Create2(caller ContractRef, code []byte, gas uint64, endowment *big.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, leftOverGas uint64, err error) {

 codeAndHash := &codeAndHash{code: code}

 contractAddr = crypto.CreateAddress2(caller.Address(), salt.Bytes32(), codeAndHash.Hash().Bytes())

 return evm.create(caller, codeAndHash, gas, endowment, contractAddr, CREATE2)

}

Enter fullscreen mode Exit fullscreen mode

Estamos apenas criando o endereço do contrato a partir do endereço do chamador e do nonce e, em seguida, chamamos uma função de criação. Adicionei também a função Create2 para ver a diferença na criação do endereço. Na segunda função, o endereço é criado a partir do endereço do chamador, do salt e do codeHash. Agora, vamos nos aprofundar nos detalhes:


func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {

 // Verificação da profundidade da execução. Falha se estivermos tentando executar acima do

 // limite.

 se evm.depth > int(params.CallCreateDepth) {

  return nil, common.Address{}, gas, ErrDepth

 }

 if !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {

  return nil, common.Address{}, gas, ErrInsufficientBalance

 }

 nonce := evm.StateDB.GetNonce(caller.Address())

 se nonce+1 < nonce {

  return nil, common.Address{}, gas, ErrNonceUintOverflow

 }

 evm.StateDB.SetNonce(caller.Address(), nonce+1)

 // Adicionamos isso à lista de acesso _antes_ de tirar um snapshot “instantâneo”. Mesmo que a criação falhe,

 // a alteração na lista de acesso não deve ser revertida

 se evm.chainRules.IsBerlin {

  evm.StateDB.AddAddressToAccessList(address)

 }

 // Garantir que não haja nenhum contrato existente no endereço designado

 contractHash := evm.StateDB.GetCodeHash(address)

 if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != emptyCodeHash) {

  return nil, common.Address{}, 0, ErrContractAddressCollision

 }

Enter fullscreen mode Exit fullscreen mode

Primeiro, a profundidade da chamada é verificada. Ela é aumentada após cada chamada. O valor máximo permitido aqui é 1024, acima desse valor a execução é revertida. Em seguida, verificamos se a conta realmente tem os fundos necessários para enviar ao construtor do contrato. Caso o valor enviado seja 0, ele retornará true. Depois de recuperar um nonce do StateDB, ele é verificado quanto a estouro. Não consigo imaginar uma situação em que o nonce estoure, já que 2²⁵⁶ * 21000 = 2,43163387398364e+81 de gás mínimo é necessário para estourar esse valor, o que significa que, mesmo em cadeias tão baratas quanto a Celo, provavelmente custaria mais do que qualquer pessoa no mundo pode pagar, mas é melhor prevenir do que remediar.

Em seguida, se for um hardfork de Berlim, chame AddAddressToAccessList(). Vamos parar por um momento, pois essa função, juntamente com AddSlotToAccessList(), é muito importante de se entender. A EVM tem um conceito de armazenamento frio e quente. Isso significa que se você estiver acessando um slot de armazenamento ou interagindo com um endereço específico pela primeira vez em uma transação (isso é chamado de "tocar"), você pagará mais do que em cada interação seguinte. Isso desincentiva os agentes mal-intencionados que tentam fazer DoS na rede por meio de transações que contêm várias leituras/gravações de armazenamento aleatório, pois cada primeira leitura exige uma operação de E/S no sistema de arquivos para recuperar o valor do armazenamento do nó.

Por fim, a EVM garante que não há código implantado nesse endereço e que nenhuma transação ocorreu nesse endereço - o nonce é 0.


// Criar uma nova conta no estado

 snapshot := evm.StateDB.Snapshot()

 evm.StateDB.CreateAccount(endereço)

 se evm.chainRules.IsEIP158 {

  evm.StateDB.SetNonce(address, 1)

 }

 evm.Context.Transfer(evm.StateDB, caller.Address(), address, value)

 // Inicializar um novo contrato e definir o código a ser usado pela EVM.

 // O contrato é um ambiente com escopo apenas para esse contexto de execução.

 contract := NewContract(caller, AccountRef(address), value, gas)

 contract.SetCodeOptionalHash(&address, codeAndHash)

 ...

 ret, err := evm.interpreter.Run(contract, nil, false)

 // Verificar se o tamanho máximo do código foi excedido, atribuir err se for o caso.

 se err == nil && evm.chainRules.IsEIP158 && len(ret) > params.MaxCodeSize {

  err = ErrMaxCodeSizeExceeded

 }

 // Rejeitar o código que começa com 0xEF se o EIP-3541 estiver ativado.

 if err == nil && len(ret) >= 1 && ret[0] == 0xEF && evm.chainRules.IsLondon {

  err = ErrInvalidCode

 }

 // se a criação do contrato foi executada com êxito e nenhum erro foi retornado

 // calcular o gás necessário para armazenar o código. Se o código não puder

 // armazenado devido à falta de gás suficiente, defina um erro e deixe-o ser tratado

 // pela condição de verificação de erro abaixo.

 se err == nil {

  createDataGas := uint64(len(ret)) * params.CreateDataGas

  se contract.UseGas(createDataGas) {

   evm.StateDB.SetCode(address, ret)

  } else {

   err = ErrCodeStoreOutOfGas

  }

 }

// Quando um erro foi retornado pela EVM ou ao definir o código de criação

 // acima, revertemos para o snapshot e consumimos todo o gás restante. Além disso

 Além disso, // quando estamos em homestead, isso também conta para erros de gás de armazenamento de código.

 se err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) {

  evm.StateDB.RevertToSnapshot(snapshot)

  se err != ErrExecutionReverted {

   contract.UseGas(contract.Gas)

  }

 }

 ...

 return ret, address, contract.Gas, err

}

// Quando um erro foi retornado pela EVM ou ao definir o código de criação

 // acima, revertemos para o snapshot e consumimos todo o gás restante. Além disso

 Além disso, // quando estamos em homestead, isso também conta para erros de gás de armazenamento de código.

 se err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) {

  evm.StateDB.RevertToSnapshot(snapshot)

  se err != ErrExecutionReverted {

   contract.UseGas(contract.Gas)

  }

 }

 ...

 return ret, address, contract.Gas, err

}

Enter fullscreen mode Exit fullscreen mode

Essa parte é realmente muito interessante. Primeiro, o estado é capturado em um snapshot. Isso é o que torna as transações EVM atômicas: toda vez que você invoca uma chamada, cria um contrato inteligente ou chama um contrato externo, a primeira coisa que é feita antes de qualquer coisa ser executada é salvar o estado do StateDB. Então, se ocorrer algum erro, o estado é simplesmente revertido para o estado salvo anteriormente. Graças a isso, não há possibilidade de haver uma chamada revertida que modifique o DB.

Em seguida, a EVM salva o endereço no banco de dados e define o nonce como 1 - o nonce dos contratos inteligentes sempre começa em 1 e aumenta somente quando o contrato inteligente cria um novo contrato inteligente por meio do código de operação CREATE ou CREATE2.

Depois que o msg.value é transferido para o contrato inteligente recém-criado, a EVM inicializa o novo contrato que é passado para o interpretador de bytecode para executar o código do contrato. Essa parte é realmente importante, mas vou adiar deliberadamente sua descrição para mais tarde, quando discutir a chamada normal. Como já mencionei antes, o que está sendo executado aqui é um construtor. Mesmo que você não defina um, o compilador do Solidity fará isso por você. Afinal de contas, você precisa executá-lo na cadeia e retornar o local do código usando o opcode RETURN no final. Lembre-se de que não há código implantado nesse momento. O construtor apenas retorna o que está prestes a ser salvo como o código do contrato inteligente. É por isso que você não deve confiar mais no tamanho do código de endereço, pois o construtor é o único lugar em que você pode executar qualquer código de operação arbitrário sem tê-lo implantado. Quando concluído, duas variáveis são retornadas: ret e err. A primeira é uma matriz de bytes que contém o contrato inteligente a ser implantado, e a segunda apenas sinaliza se ocorreu algum erro. Depois disso, verificamos se o tamanho do código é menor do que o limite atual e se não começa com 0xEF. Esse requisito foi adicionado aqui para o futuro EOF. Você pode encontrar mais detalhes sobre isso AQUI.

Por fim, a EVM consome o gás devido por byte de contrato inteligente, só agora salva o código no StateDB ou, se ocorrer um erro, reverte o estado e propaga o erro mais alto.

Execução de chamadas

Vamos nos concentrar agora no segundo caminho mencionado na Parte I - execução normal de chamadas. Algumas das partes são semelhantes aqui:


// A chamada executa o contrato associado ao endereço com a entrada fornecida como

// parâmetros. Ela também lida com qualquer transferência de valor necessária e toma

// as etapas necessárias para criar contas e reverte o estado no caso de um

// erro de execução ou falha na transferência de valores.

func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {

 // Falha se estivermos tentando executar acima do limite de profundidade da chamada

 se evm.depth > int(params.CallCreateDepth) {

  return nil, gas, ErrDepth

 }

 // Falha se estivermos tentando transferir mais do que o saldo disponível

 if value.Sign() != 0 && !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {

  return nil, gas, ErrInsufficientBalance

 }

 snapshot := evm.StateDB.Snapshot()

 p, isPrecompile := evm.precompile(addr)

 se !evm.StateDB.Exist(addr) {

  if !isPrecompile && evm.chainRules.IsEIP158 && value.Sign() == 0 {

   // Chamando uma conta não existente, não faça nada, mas faça ping no rastreador

   se evm.Config.Debug {

    se evm.depth == 0 {

     evm.Config.Tracer.CaptureStart(evm, caller.Address(), addr, false, input, gas, value)

     evm.Config.Tracer.CaptureEnd(ret, 0, nil)

    } else {

     evm.Config.Tracer.CaptureEnter(CALL, caller.Address(), addr, input, gas, value)

     evm.Config.Tracer.CaptureExit(ret, 0, nil)

 }

   }

   return nil, gas, nil

  }

  evm.StateDB.CreateAccount(addr)

 }

 evm.Context.Transfer(evm.StateDB, caller.Address(), addr, value)

Enter fullscreen mode Exit fullscreen mode

Primeiro, verificamos se não ultrapassamos a profundidade da pilha de 1024 chamadas e se temos valor suficiente para transferir para o destinatário da transação. Em seguida, a EVM captura o estado e verifica se o destinatário é um contrato pré-compilado e retorna o ponteiro para ele, se for o caso. Se não for, e o endereço ainda não existir, ele o criará, mas SOMENTE SE o valor a ser enviado para esse endereço for maior que 0. Caso contrário, ele fará um retorno antecipado. Vamos nos aprofundar:


se isPrecompile {

  ret, gas, err = RunPrecompiledContract(p, input, gas)

 } else {

// Inicializa um novo contrato e define o código que deve ser usado pela EVM.

  // O contrato é um ambiente com escopo apenas para esse contexto de execução.

  código := evm.StateDB.GetCode(addr)

  se len(code) == 0 {

   ret, err = nil, nil // o gás não foi alterado

  } else {

   addrCopy := addr

   // Se a conta não tiver código, podemos abortar aqui

   // A verificação de profundidade já foi feita e as pré-compilações tratadas acima

   contract := NewContract(caller, AccountRef(addrCopy), value, gas)

   contract.SetCallCode(&addrCopy, evm.StateDB.GetCodeHash(addrCopy), code)

   ret, err = evm.interpreter.Run(contract, input, false)

   gas = contract.Gas

  }

 }

// Quando um erro foi retornado pela EVM ou ao definir o código de criação

 // acima, reverteremos para o snapshot e consumiremos todo o gás restante. Além disso

 // Além disso, quando estamos na propriedade, isso também conta para erros de gás de armazenamento de código.

 se err != nil {

  evm.StateDB.RevertToSnapshot(snapshot)

  se err != ErrExecutionReverted {

   gas = 0

  }

  // TODO: considere a possibilidade de limpar os snapshots não utilizados:

  //} else {

  // evm.StateDB.DiscardSnapshot(snapshot)

 }

 return ret, gas, err

}

Enter fullscreen mode Exit fullscreen mode

A partir daqui, agora há diferentes caminhos possíveis. Se for uma pré-compilação, será usada uma função especial do core/vm/contracts.go. Isso ocorre porque os contratos pré-compilados são especiais. Eles não são implantados na cadeia, mas vivem diretamente no código do cliente de execução. Portanto, mesmo que tenham tamanho de código 0, eles ainda executam funções específicas, pois são executados nativamente. Cada função de pré-compilação tem seu próprio endereço. Você pode encontrar mais informações sobre pré-compilações neste LINK. Vamos dar uma olhada no código de implementação do ecrecover (endereço 0x01), que pode ser encontrado AQUI:


// ECRECOVER implementado como um contrato nativo.

type ecrecover struct{}

func (c *ecrecover) RequiredGas(input []byte) uint64 {

 return params.EcrecoverGas

}

func (c *ecrecover) Run(input []byte) ([]byte, error) {

 const ecRecoverInputLength = 128

 input = common.RightPadBytes(input, ecRecoverInputLength)

 // "input" é (hash, v, r, s), cada um com 32 bytes

 // mas para a recuperação eletrônica, queremos (r, s, v)

 r := new(big.Int).SetBytes(input[64:96])

 s := new(big.Int).SetBytes(input[96:128])

 v := input[63] - 27

 // valores mais rígidos de sig s input homestead só se aplicam a sigs tx

 if !allZero(input[32:63]) || !crypto.ValidateSignatureValues(v, r, s, false) {

  return nil, nil

 }

 // Precisamos nos certificar de não modificar a 'entrada', portanto, colocar o 'v' junto com

 // a assinatura precisa ser feita em uma nova alocação

 sig := make([]byte, 65)

 copy(sig, input[64:128])

 sig[64] = v

 // v precisa estar no final para a libsecp256k1

 pubKey, err := crypto.Ecrecover(input[:32], sig)

 // certificar-se de que a chave pública é válida

 se err != nil {

  return nil, nil

 }

 // o primeiro byte da pubkey é o patrimônio do bitcoin

 return common.LeftPadBytes(crypto.Keccak256(pubKey[1:])[12:], 32), nil

}

Enter fullscreen mode Exit fullscreen mode

Se não for uma pré-compilação, a EVM obtém uma matriz de bytes de código. Se seu comprimento for 0, a chamada retorna aqui com um valor de retorno nulo. É por isso que as chamadas de baixo nível no Solidity retornam com sucesso e você mesmo deve verificar se o endereço é um contrato inteligente. Em seguida, preenchemos a nova estrutura Contract e a passamos ao interpretador para executar o contrato inteligente. No final, a EVM verifica se ocorreu algum erro, revertendo o estado nesse caso, e retorna o resultado da execução juntamente com o gás restante e o erro, se houver.

Por enquanto, é isso. Na Parte III, veremos como funciona o interpretador de bytecode. Se quiser ler mais sobre meus artigos, siga-me no Twitter. Se precisar de uma revisão 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)