Pular para o conteúdo principal

Entendendo as especificações da EVM no yellow paper

evm
Intermediário
qbzzt
15 de maio de 2022
19 minutos de leitura

O yellow paper (abre em uma nova aba) é a especificação formal do Ethereum. Exceto onde alterado pelo processo de EIP, ele contém a descrição exata de como tudo funciona. Ele é escrito como um artigo matemático, o que inclui terminologia com a qual os programadores podem não estar familiarizados. Neste artigo, você aprenderá como lê-lo e, por extensão, outros artigos matemáticos relacionados.

Qual yellow paper?

Como quase tudo no Ethereum, o yellow paper evolui com o tempo. Para poder me referir a uma versão específica, fiz o upload da versão atual no momento da escrita (abre em uma nova aba). Os números de seção, página e equação que uso se referirão a essa versão. É uma boa ideia mantê-lo aberto em uma janela diferente enquanto lê este documento.

Por que a EVM?

O yellow paper original foi escrito logo no início do desenvolvimento do Ethereum. Ele descreve o mecanismo de consenso original baseado em Prova de Trabalho (PoW) que foi usado inicialmente para proteger a rede. No entanto, o Ethereum desativou a Prova de Trabalho e começou a usar o consenso baseado em Prova de Participação (PoS) em setembro de 2022. Este tutorial se concentrará nas partes do yellow paper que definem a Máquina Virtual Ethereum. A EVM não foi alterada pela transição para a Prova de Participação (exceto pelo valor de retorno do opcode DIFFICULTY).

9 Modelo de execução

Esta seção (p. 14-16) inclui a maior parte da definição da EVM.

O termo estado do sistema inclui tudo o que você precisa saber sobre o sistema para executá-lo. Em um computador típico, isso significa a memória, o conteúdo dos registradores, etc.

Uma máquina de Turing (abre em uma nova aba) é um modelo computacional. Essencialmente, é uma versão simplificada de um computador, que provou ter a mesma capacidade de executar cálculos que um computador normal pode (tudo o que um computador pode calcular, uma máquina de Turing pode calcular e vice-versa). Esse modelo torna mais fácil provar vários teoremas sobre o que é e o que não é computável.

O termo Turing-completo (abre em uma nova aba) significa um computador que pode executar os mesmos cálculos que uma máquina de Turing. As máquinas de Turing podem entrar em loops infinitos, e a EVM não pode porque ficaria sem gás, então ela é apenas quase Turing-completa.

9.1 Conceitos básicos

Esta seção apresenta os conceitos básicos da EVM e como ela se compara a outros modelos computacionais.

Uma máquina de pilha (abre em uma nova aba) é um computador que armazena dados intermediários não em registradores, mas em uma pilha (abre em uma nova aba). Esta é a arquitetura preferida para máquinas virtuais porque é fácil de implementar, o que significa que bugs e vulnerabilidades de segurança são muito menos prováveis. A memória na pilha é dividida em palavras de 256 bits. Isso foi escolhido porque é conveniente para as principais operações criptográficas do Ethereum, como a geração de hash Keccak-256 e cálculos de curva elíptica. O tamanho máximo da pilha é de 1024 itens (1024 x 256 bits). Quando os opcodes são executados, eles geralmente obtêm seus parâmetros da pilha. Existem opcodes especificamente para reorganizar elementos na pilha, como POP (remove o item do topo da pilha), DUP_N (duplica o enésimo item na pilha), etc.

A EVM também tem um espaço volátil chamado memória, que é usado para armazenar dados durante a execução. Essa memória é organizada em palavras de 32 bytes. Todos os locais de memória são inicializados com zero. Se você executar este código Yul (abre em uma nova aba) para adicionar uma palavra à memória, ele preencherá 32 bytes de memória preenchendo o espaço vazio na palavra com zeros, ou seja, ele cria uma palavra - com zeros nos locais 0-29, 0x60 no 30 e 0xA7 no 31.

mstore(0, 0x60A7)

mstore é um dos três opcodes que a EVM fornece para interagir com a memória - ele carrega uma palavra na memória. Os outros dois são mstore8, que carrega um único byte na memória, e mload, que move uma palavra da memória para a pilha.

A EVM também tem um modelo de armazenamento (storage) não volátil separado que é mantido como parte do estado do sistema - essa memória é organizada em matrizes de palavras (em oposição a matrizes de bytes endereçáveis por palavra na pilha). Esse armazenamento é onde os contratos mantêm dados persistentes - um contrato só pode interagir com seu próprio armazenamento. O armazenamento é organizado em mapeamentos de chave-valor.

Embora não seja mencionado nesta seção do yellow paper, também é útil saber que existe um quarto tipo de memória. Calldata (dados de chamada) é uma memória somente leitura endereçável por byte usada para armazenar o valor passado com o parâmetro data de uma transação. A EVM tem opcodes específicos para gerenciar calldata. calldatasize retorna o tamanho dos dados. calldataload carrega os dados na pilha. calldatacopy copia os dados para a memória.

A arquitetura de Von Neumann (abre em uma nova aba) padrão armazena código e dados na mesma memória. A EVM não segue esse padrão por motivos de segurança - compartilhar memória volátil torna possível alterar o código do programa. Em vez disso, o código é salvo no armazenamento.

Existem apenas dois casos em que o código é executado a partir da memória:

  • Quando um contrato cria outro contrato (usando CREATE (abre em uma nova aba) ou CREATE2 (abre em uma nova aba)), o código para o construtor do contrato vem da memória.
  • Durante a criação de qualquer contrato, o código do construtor é executado e, em seguida, retorna com o código do contrato real, também da memória.

O termo execução excepcional significa uma exceção que faz com que a execução do contrato atual seja interrompida.

9.2 Visão geral das taxas

Esta seção explica como as taxas de gás são calculadas. Existem três custos:

Custo do opcode

O custo inerente do opcode específico. Para obter esse valor, encontre o grupo de custo do opcode no Apêndice H (p. 29, sob a equação (329)) e encontre o grupo de custo na equação (326). Isso fornece uma função de custo, que na maioria dos casos usa parâmetros do Apêndice G (p. 28).

Por exemplo, o opcode CALLDATACOPY (abre em uma nova aba) é um membro do grupo Wcopy. O custo do opcode para esse grupo é Gverylow+Gcopy×⌈μs[2]÷32⌉. Olhando para o Apêndice G, vemos que ambas as constantes são 3, o que nos dá 3+3×⌈μs[2]÷32⌉.

Ainda precisamos decifrar a expressão ⌈μs[2]÷32⌉. A parte mais externa, ⌈ <value> ⌉ é a função teto (ceiling function), uma função que, dado um valor, retorna o menor número inteiro que ainda não é menor que o valor. Por exemplo, ⌈2.5⌉ = ⌈3⌉ = 3. A parte interna é μs[2]÷32. Olhando para a seção 3 (Convenções) na p. 3, μ é o estado da máquina. O estado da máquina é definido na seção 9.4.1 na p. 15. De acordo com essa seção, um dos parâmetros do estado da máquina é s para a pilha (stack). Juntando tudo, parece que μs[2] é a posição #2 na pilha. Olhando para o opcode (abre em uma nova aba), a posição #2 na pilha é o tamanho dos dados em bytes. Olhando para os outros opcodes no grupo Wcopy, CODECOPY (abre em uma nova aba) e RETURNDATACOPY (abre em uma nova aba), eles também têm um tamanho de dados na mesma posição. Portanto, ⌈μs[2]÷32⌉ é o número de palavras de 32 bytes necessárias para armazenar os dados sendo copiados. Juntando tudo, o custo inerente de CALLDATACOPY (abre em uma nova aba) é 3 de gás mais 3 por palavra de dados sendo copiada.

Custo de execução

O custo de executar o código que estamos chamando.

Custo de expansão de memória

O custo de expandir a memória (se necessário).

Na equação 326, esse valor é escrito como Cmemi')-Cmemi). Olhando para a seção 9.4.1 novamente, vemos que μi é o número de palavras na memória. Portanto, μi é o número de palavras na memória antes do opcode e μi' é o número de palavras na memória após o opcode.

A função Cmem é definida na equação 328: Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋. ⌊x⌋ é a função piso (floor function), uma função que, dado um valor, retorna o maior número inteiro que ainda não é maior que o valor. Por exemplo, ⌊2.5⌋ = ⌊2⌋ = 2. Quando a < √512, a2 < 512, e o resultado da função piso é zero. Portanto, para as primeiras 22 palavras (704 bytes), o custo aumenta linearmente com o número de palavras de memória necessárias. Além desse ponto, ⌊a2 ÷ 512⌋ é positivo. Quando a memória necessária é alta o suficiente, o custo de gás é proporcional ao quadrado da quantidade de memória.

Nota que esses fatores influenciam apenas o custo inerente de gás - não leva em consideração o mercado de taxas ou gorjetas para validadores que determinam quanto um usuário final deve pagar - este é apenas o custo bruto de executar uma operação específica na EVM.

Leia mais sobre gás.

9.3 Ambiente de execução

O ambiente de execução é uma tupla, I, que inclui informações que não fazem parte do estado da blockchain ou da EVM.

ParâmetroOpcode para acessar os dadosCódigo Solidity para acessar os dados
IaADDRESS (abre em uma nova aba)address(this)
IoORIGIN (abre em uma nova aba)tx.origin
IpGASPRICE (abre em uma nova aba)tx.gasprice
IdCALLDATALOAD (abre em uma nova aba), etc.msg.data
IsCALLER (abre em uma nova aba)msg.sender
IvCALLVALUE (abre em uma nova aba)msg.value
IbCODECOPY (abre em uma nova aba)address(this).code
IHCampos do cabeçalho do bloco, como NUMBER (abre em uma nova aba) e DIFFICULTY (abre em uma nova aba)block.number, block.difficulty, etc.
IeProfundidade da pilha de chamadas para chamadas entre contratos (incluindo a criação de contratos)
IwA EVM tem permissão para alterar o estado ou está sendo executada estaticamente

Alguns outros parâmetros são necessários para entender o restante da seção 9:

ParâmetroDefinido na seçãoSignificado
σ2 (p. 2, equação 1)O estado da blockchain
g9.3 (p. 14)Gás restante
A6.1 (p. 9)Subestado acumulado (alterações programadas para quando a transação terminar)
o9.3 (p. 14)Saída - o resultado retornado no caso de transação interna (quando um contrato chama outro) e chamadas para funções de visualização (view functions) (quando você está apenas pedindo informações, então não há necessidade de esperar por uma transação)

9.4 Visão geral da execução

Agora que temos todas as preliminares, podemos finalmente começar a trabalhar em como a EVM funciona.

As equações 146-151 nos dão as condições iniciais para executar a EVM:

SímboloValor inicialSignificado
μggGás restante
μpc0Contador de programa (program counter), o endereço da próxima instrução a ser executada
μm(0, 0, ...)Memória, inicializada com todos os zeros
μi0Posição de memória mais alta usada
μs()A pilha, inicialmente vazia
μoA saída, conjunto vazio até e a menos que paremos com dados de retorno (RETURN (abre em uma nova aba) ou REVERT (abre em uma nova aba)) ou sem eles (STOP (abre em uma nova aba) ou SELFDESTRUCT (abre em uma nova aba)).

A equação 152 nos diz que existem quatro condições possíveis em cada ponto no tempo durante a execução, e o que fazer com elas:

  1. Z(σ,μ,A,I). Z representa uma função que testa se uma operação cria uma transição de estado inválida (veja parada excepcional). Se for avaliada como Verdadeira, o novo estado é idêntico ao antigo (exceto que o gás é queimado) porque as alterações não foram implementadas.
  2. Se o opcode sendo executado for REVERT (abre em uma nova aba), o novo estado é o mesmo que o estado antigo, algum gás é perdido.
  3. Se a sequência de operações for concluída, conforme indicado por um RETURN (abre em uma nova aba)), o estado é atualizado para o novo estado.
  4. Se não estivermos em uma das condições finais 1-3, continue a execução.

9.4.1 Estado da máquina

Esta seção explica o estado da máquina com mais detalhes. Ela especifica que w é o opcode atual. Se μpc for menor que ||Ib||, o comprimento do código, então esse byte (Ibpc]) é o opcode. Caso contrário, o opcode é definido como STOP (abre em uma nova aba).

Como esta é uma máquina de pilha (abre em uma nova aba), precisamos acompanhar o número de itens retirados (δ) e inseridos (α) por cada opcode.

9.4.2 Parada excepcional

Esta seção define a função Z, que especifica quando temos um término anormal. Esta é uma função booleana (abre em uma nova aba), então ela usa para um ou lógico (abre em uma nova aba) e para um e lógico (abre em uma nova aba).

Temos uma parada excepcional se qualquer uma destas condições for verdadeira:

  • μg < C(σ,μ,A,I) Como vimos na seção 9.2, C é a função que especifica o custo de gás. Não há gás suficiente restante para cobrir o próximo opcode.

  • δw=∅ Se o número de itens retirados (popped) para um opcode for indefinido, então o próprio opcode é indefinido.

  • || μs || < δw Underflow da pilha, não há itens suficientes na pilha para o opcode atual.

  • w = JUMP ∧ μs[0]∉D(Ib) O opcode é JUMP (abre em uma nova aba) e o endereço não é um JUMPDEST (abre em uma nova aba). Os saltos (jumps) são apenas válidos quando o destino é um JUMPDEST (abre em uma nova aba).

  • w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) O opcode é JUMPI (abre em uma nova aba), a condição é verdadeira (diferente de zero), então o salto deve acontecer, e o endereço não é um JUMPDEST (abre em uma nova aba). Os saltos são apenas válidos quando o destino é um JUMPDEST (abre em uma nova aba).

  • w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || O opcode é RETURNDATACOPY (abre em uma nova aba). Neste opcode, o elemento da pilha μs[1] é o deslocamento (offset) para ler no buffer de dados de retorno, e o elemento da pilha μs[2] é o comprimento dos dados. Essa condição ocorre quando você tenta ler além do final do buffer de dados de retorno. Observe que não há uma condição semelhante para os dados de chamada (calldata) ou para o próprio código. Quando você tenta ler além do final desses buffers, você apenas obtém zeros.

  • || μs || - δw + αw > 1024

    Overflow da pilha. Se a execução do opcode resultar em uma pilha de mais de 1024 itens, aborte.

  • ¬Iw ∧ W(w,μ) Estamos executando estaticamente (¬ é negação (abre em uma nova aba) e Iw é verdadeiro quando temos permissão para alterar o estado da blockchain)? Se sim, e estamos tentando uma operação de alteração de estado, ela não pode acontecer.

    A função W(w,μ) é definida posteriormente na equação 159. W(w,μ) é verdadeira se uma destas condições for verdadeira:

    • w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} Esses opcodes alteram o estado, seja criando um novo contrato, armazenando um valor ou destruindo o contrato atual.

    • LOG0≤w ∧ w≤LOG4 Se formos chamados estaticamente, não podemos emitir entradas de log. Os opcodes de log estão todos no intervalo entre LOG0 (A0) (abre em uma nova aba) e LOG4 (A4) (abre em uma nova aba). O número após o opcode de log especifica quantos tópicos a entrada de log contém.

    • w=CALL ∧ μs[2]≠0 Você pode chamar outro contrato quando estiver estático, mas se o fizer, não poderá transferir ETH para ele.

  • w = SSTORE ∧ μg ≤ Gcallstipend Você não pode executar SSTORE (abre em uma nova aba) a menos que tenha mais de Gcallstipend (definido como 2300 no Apêndice G) de gás.

9.4.3 Validade do destino do salto

Aqui definimos formalmente quais são os opcodes JUMPDEST (abre em uma nova aba). Não podemos apenas procurar pelo valor de byte 0x5B, porque ele pode estar dentro de um PUSH (e, portanto, dados e não um opcode).

Na equação (162) definimos uma função, N(i,w). O primeiro parâmetro, i, é a localização do opcode. O segundo, w, é o próprio opcode. Se w∈[PUSH1, PUSH32] isso significa que o opcode é um PUSH (colchetes definem um intervalo que inclui as extremidades). Nesse caso, o próximo opcode está em i+2+(w−PUSH1). Para PUSH1 (abre em uma nova aba) precisamos avançar dois bytes (o próprio PUSH e o valor de um byte), para PUSH2 (abre em uma nova aba) precisamos avançar três bytes porque é um valor de dois bytes, etc. Todos os outros opcodes da EVM têm apenas um byte de comprimento, então em todos os outros casos N(i,w)=i+1.

Esta função é usada na equação (161) para definir DJ(c,i), que é o conjunto (abre em uma nova aba) de todos os destinos de salto válidos no código c, começando com a localização do opcode i. Esta função é definida recursivamente. Se i≥||c||, isso significa que estamos no final ou após o final do código. Não vamos encontrar mais nenhum destino de salto, então apenas retorne o conjunto vazio.

Em todos os outros casos, olhamos para o resto do código indo para o próximo opcode e obtendo o conjunto a partir dele. c[i] é o opcode atual, então N(i,c[i]) é a localização do próximo opcode. DJ(c,N(i,c[i])) é, portanto, o conjunto de destinos de salto válidos que começa no próximo opcode. Se o opcode atual não for um JUMPDEST, apenas retorne esse conjunto. Se for JUMPDEST, inclua-o no conjunto de resultados e retorne-o.

9.4.4 Parada normal

A função de parada H pode retornar três tipos de valores.

  • Se não estivermos em um opcode de parada, retorne , o conjunto vazio. Por convenção, esse valor é interpretado como falso booleano.
  • Se tivermos um opcode de parada que não produz saída (seja STOP (abre em uma nova aba) ou SELFDESTRUCT (abre em uma nova aba)), retorne uma sequência de bytes de tamanho zero como valor de retorno. Observe que isso é muito diferente do conjunto vazio. Esse valor significa que a EVM realmente parou, apenas não há dados de retorno para ler.
  • Se tivermos um opcode de parada que produz saída (seja RETURN (abre em uma nova aba) ou REVERT (abre em uma nova aba)), retorne a sequência de bytes especificada por esse opcode. Essa sequência é retirada da memória, o valor no topo da pilha (μs[0]) é o primeiro byte e o valor depois dele (μs[1]) é o comprimento.

H.2 Conjunto de instruções

Antes de irmos para a subseção final da EVM, 9.5, vamos olhar para as próprias instruções. Elas são definidas no Apêndice H.2, que começa na p. 30. Espera-se que qualquer coisa que não seja especificada como alterada com esse opcode específico permaneça a mesma. As variáveis que mudam são especificadas com um <algo>′.

Por exemplo, vamos olhar para o opcode ADD (abre em uma nova aba).

ValorMnemônicoδαDescrição
0x01ADD21Operação de adição.
μ′s[0] ≡ μs[0] + μs[1]

δ é o número de valores que retiramos (pop) da pilha. Neste caso, dois, porque estamos somando os dois valores do topo.

α é o número de valores que inserimos (push) de volta. Neste caso, um, a soma.

Portanto, o novo topo da pilha (μ′s[0]) é a soma do antigo topo da pilha (μs[0]) e o valor antigo abaixo dele (μs[1]).

Em vez de repassar todos os opcodes com uma "lista de dar sono", este artigo explica apenas os opcodes que introduzem algo novo.

ValorMnemônicoδαDescrição
0x20KECCAK25621Calcula o hash Keccak-256.
μ′s[0] ≡ KEC(μms[0] . . . (μs[0] + μs[1] − 1)])
μ′i ≡ M(μis[0],μs[1])

Este é o primeiro opcode que acessa a memória (neste caso, somente leitura). No entanto, ele pode se expandir além dos limites atuais da memória, então precisamos atualizar μi. Fazemos isso usando a função M definida na equação 330 na p. 30.

ValorMnemônicoδαDescrição
0x31BALANCE11Obtém o saldo da conta fornecida.
...

O endereço cujo saldo precisamos encontrar é μs[0] mod 2160. O topo da pilha é o endereço, mas como os endereços têm apenas 160 bits, calculamos o valor módulo (abre em uma nova aba) 2160.

Se σ[μs[0] mod 2160] ≠ ∅, significa que há informações sobre este endereço. Nesse caso, σ[μs[0] mod 2160]b é o saldo para esse endereço. Se σ[μs[0] mod 2160] = ∅, significa que este endereço não foi inicializado e o saldo é zero. Você pode ver a lista de campos de informações da conta na seção 4.1 na p. 4.

A segunda equação, A'a ≡ Aa ∪ {μs[0] mod 2160}, está relacionada à diferença de custo entre o acesso ao armazenamento quente (armazenamento que foi acessado recentemente e provavelmente está em cache) e armazenamento frio (armazenamento que não foi acessado e provavelmente está em um armazenamento mais lento que é mais caro para recuperar). Aa é a lista de endereços acessados anteriormente pela transação, que, portanto, devem ser mais baratos de acessar, conforme definido na seção 6.1 na p. 9. Você pode ler mais sobre este assunto na EIP-2929 (abre em uma nova aba).

ValorMnemônicoδαDescrição
0x8FDUP161617Duplica o 16º item da pilha.
μ′s[0] ≡ μs[15]

Observe que para usar qualquer item da pilha, precisamos retirá-lo (pop), o que significa que também precisamos retirar todos os itens da pilha em cima dele. No caso de DUP<n> (abre em uma nova aba) e SWAP<n> (abre em uma nova aba), isso significa ter que retirar e depois inserir até dezesseis valores.

9.5 O ciclo de execução

Agora que temos todas as partes, podemos finalmente entender como o ciclo de execução da EVM é documentado.

A equação (164) diz que dado o estado:

  • σ (estado global da blockchain)
  • μ (estado da EVM)
  • A (subestado, alterações que ocorrerão quando a transação terminar)
  • I (ambiente de execução)

O novo estado é (σ', μ', A', I').

As equações (165)-(167) definem a pilha e a alteração nela devido a um opcode (μs). A equação (168) é a alteração no gás (μg). A equação (169) é a alteração no contador de programa (μpc). Finalmente, as equações (170)-(173) especificam que os outros parâmetros permanecem os mesmos, a menos que sejam explicitamente alterados pelo opcode.

Com isso, a EVM está totalmente definida.

Conclusão

A notação matemática é precisa e permitiu que o yellow paper especificasse todos os detalhes do Ethereum. No entanto, ela tem algumas desvantagens:

  • Ela só pode ser entendida por humanos, o que significa que os testes de conformidade (abre em uma nova aba) devem ser escritos manualmente.
  • Os programadores entendem código de computador. Eles podem ou não entender a notação matemática.

Talvez por esses motivos, as especificações da camada de consenso (abre em uma nova aba) mais recentes sejam escritas em Python. Existem especificações da camada de execução em Python (abre em uma nova aba), mas elas não estão completas. Até e a menos que todo o yellow paper também seja traduzido para Python ou uma linguagem semelhante, o yellow paper continuará em serviço, e é útil poder lê-lo.