Cavando fundo na mecânica da EVM durante chamadas de função de contrato
27 fevereiro
A ideia de primeiros princípios é um termo que ouvimos com frequência. Ele foca em aprender profundamente os conceitos fundamentais de um assunto para permitir uma melhor reflexão, no espaço de design, sobre componentes que são construídos por cima.
No mundo do smart contract, a “Ethereum Virtual Machine” (Máquina Virtual do Ethereum) junto com seus algoritmos e estruturas de dados são os primeiros princípios. Solidity e os smart contracts que criamos são os componentes construídos por cima dessa fundação. Para ser um ótimo desenvolvedor da solidity deve-se ter um profundo conhecimento da EVM.
Este é o primeiro de uma série de artigos que mergulharão profundamente na EVM e construirão esse conhecimento fundamental necessário para se tornar um “shadowy super coder”.
O Básico: Solidity → Bytecode → Opcode
Antes de começarmos, esse artigo presume um conhecimento básico de solidity e de como ele é implantado na chain do Ethereum. Nós tocaremos nesse assunto brevemente, contudo se você quiser fazer uma reciclagem, veja esse artigo aqui.
Como você sabe, seu código solidity precisa ser compilado para o bytecode antes de ser implantado na rede Ethereum. Esse bytecode corresponde a uma série de instruções opcode que a EVM interpreta.
Essa série vai focar em partes específicas do bytecode compilado e esclarecerá como ele funciona. Ao final de cada artigo, você deve ter uma compreensão muito mais clara de como cada componente funciona. Ao longo do caminho, você vai aprender muitos conceitos fundamentais relativos à EVM.
Hoje, vamos dar uma olhada num contrato básico solidity juntamente com um trecho de seu bytecode/opcodes para demonstrar como a EVM seleciona as funções.
O bytecode de tempo de execução criado pelos contratos solidity é uma representação do contrato inteiro. Dentro do contrato, você pode ter várias funções que podem ser chamadas uma vez que ele seja implementado.
Uma pergunta frequente é como a EVM sabe qual bytecode executar dependendo de qual função do contrato é chamada. Essa é a primeira pergunta que nós vamos usar para ajudar a entender a mecânica estrutural da EVM e como esse caso particular é tratado.
1_Storage.sol Breakdown
Para nossa demo, vamos usar o contrato 1_Storage.sol, que é um dos contratos default no IDE Remix
O contrato possui duas funções, store(uint256) e retrieve() entre as quais a EVM terá que decidir quando a função call entrar. Abaixo está o bytecode do tempo de execução compilado do contrato inteiro.
608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea2646970667358221220404e37f487a89a932dca5e77faaf6ca2de3b991f93d230604b1b8daaef64766264736f6c63430008070033
Vamos focar no trecho do bytecode abaixo. O trecho representa a lógica do seletor de função. Execute “ctrl f” no trecho para verificar que ele está no bytecode acima.
60003560e01c80632e64cec11461003b5780636057361d1461005957
Esse bytecode corresponde a um conjunto de opcodes da EVM e seus valores de input. Você pode conferir a lista de opcodes da EVM opcodes aqui.
Opcodes têm 1 byte de comprimento levando a 256 diferentes opcodes possíveis. A EVM só usa 140 opcodes únicos.
Abaixo pode-se observar o trecho do bytecode quebrado em seus comandos opcode correspondentes. Esses são executados sequencialmente na call stack pela EVM. Você pode navegar no link acima para verificar o opcode número 60 = PUSH1 etc. Ao final do artigo, você terá uma compreensão completa do que eles fazem.
0 00 = PUSH1 0x00
35 = CALLDATALOAD
60 e0 = PUSH1 0xe0
1c = SHR
80 = DUP1
63 2e64cec1 = PUSH4 0x2e64cec1
14 = EQ
61 003b = PUSH2 0x003b
57 = JUMPI
80 = DUP1
63 6057361d = PUSH4 0x6057361d
14 = EQ
61 0059 = PUSH2 0x0059
57 = JUMPI
Função Calls & Calldata do Smart Contract
Antes de mergulhar fundo nos opcodes precisamos passar rapidamente em como nós chamamos uma função contrato.
Quando chamamos uma função contrato, nós incluímos alguns calldata que especificam a assinatura da função a qual estamos chamando e quaisquer argumentos que precisam ser passados.
Isso pode se feito no solidity com o seguinte:
Aqui estamos fazendo uma chamada de contrato para a função store com o argumento 10. Nós usamos abi.encodeWithSignature() para obter o calldata no formato desejado. A emissão registra nosso calldata para teste.
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
Acima temos o que o abi.encodeWithSignature("store(uint256)", 10) retorna.
Anteriormente eu mencionei assinaturas de função, agora vamos olhar de perto o que elas são.
Assinaturas de função são definidas como os primeiros quatro bytes do hash Keccak da representação canônica da assinatura da função.
A representação canônica da assinatura de função é o nome da função junto com os tipos de argumento da função, ou seja, “store(uint256)” & “retrieve()”. Tente fazer, você mesmo, o hash store(uint256) para verificar os resultados aqui.
keccak256(“store(uint256)”) → primeiros quatro _bytes_ = 6057361d
keccak256(“retrieve()”) → primeiros quatro _bytes_ = 2e64cec1
Olhando para nossos calldata acima podemos ver que temos 36 bytes de calldata, os primeiros 4 bytes dos nosso calldata correspondem ao seletor de função que acabamos de computar para a função store(uint256).
Os 32 bytes restantes correspondem ao nosso argumento de input uint256. Temos um valor hexadecimal de “a” que é igual a 10 em decimal.
6057361d = function signature (4 bytes)
000000000000000000000000000000000000000000000000000000000000000a = uint256 input (32 bytes)
Se nós pegarmos a assinatura de função 6057361d e retomarmos a seção do opcode, execute ctrl f nesse valor e veja o que vai encontrar.
Opcodes & Call Stacks
Agora nós temos tudo que precisamos para dar início ao nosso mergulho profundo no que acontece no nível da EVM durante a seleção da função.
Vamos passar rapidamente em cada um dos comandos opcode, o que eles fazem e como são afetados pela call stack.
Se você não é familiarizado com o funcionamento da estrutura de dados de uma stack, veja esse vídeo rápido, como uma cartilha.
Começamos com PUSH1 que diz à EVM para levar o próximo 1 byte _de dados, 0x00 (0 em decimal), para uma _call stack. Por que fazemos isso, vai ficar claro com o próximo opcode.
Em seguida, temos CALLDATALOAD que exibe o primeiro valor na stack (0) como input.
Esse opcode carrega no calldata para a stack, usando o “input” como um deslocamento. Itens da stack têm 32 bytes, mas nosso calldata tem 36 bytes. O valor levado é msg.data[i:i+32] onde “i” é o input. Isso garante que somente 32 bytes sejam levados para a stack, mas nos permite acessar qualquer parte do calldata.
Nesse caso, não temos deslocamento (o valor que apareceu da stack foi 0 do PUSH1 anterior), então enviamos os primeiros 32 bytes do calldata para a call stack.
Lembre-se de que antes registramos nosso calldata por meio de emissão que ficou assim “0x6057361d000000000000000000000000000000000000000000000000000000000000000a”.
Isso significa que os últimos 4 bytes (“0000000a”) são perdidos. Se nós quiséssemos acessar a variável uint256 nós teríamos usado um deslocamento de 4 para omitir a assinatura da função, mas incluir a variável toda.
Outro PUSH1 dessa vez, com o valor hexadecimal 0xe0 que tem um valor decimal de 224. Lembre-se de que as assinaturas de função têm o comprimento de 4 bytes ou 32 bits. Nosso calldata carregado tem comprimento de 32 bytes ou 256 bits. 256 - 32 = 224 você pode ver aonde isso está indo.
Em seguida, temos o SHR que é um pouco deslocado para a direita. Ele pega o primeiro item da stack (224) como um input de quanto deve ser deslocado e o segundo item da stack (0x6057361d0…0a) representa o que deve ser deslocado. Podemos ver que depois dessa operação temos nosso seletor de função de 4 bytes na call stack.
Se você não estiver familiarizado com o funcionamento de deslocamentos de bits, veja este curto vídeo.
O próximo é o DUP1, um opcode simples que pega o valor do topo da stack e o duplica.
PUSH4 leva a assinatura de função de 4 bytes do retrieve() (0x2e64cec1) para a call stack.
No caso de você estar se perguntando como ele conhece esse valor, lembre-se de que ele está no bytecode que foi compilado a partir do código solidity. O compilador, portanto, tinha a informação em todos os nomes da função e tipos de argumento.
EQ retira 2 valores da stack, 0x2e64cec1 & 0x6057361d e checa se eles são iguais. Se forem, ele leva um 1 de volta para a pilha, se não, um 0.
PUSH2 retira 2 bytes dos dados para a call stack 0x003b em hexadecimal que é igual a 59 em decimal.
A call stack tem algo chamado contador do programa que especifica onde, no bytecode, está o próximo comando de execução. Aqui nós definimos 59 porque este é o local para o início do bytecode. (Note que a seção do EVM Playground abaixo vai ajudar a delinear como isso funciona).
Você pode ver a localização do contador do programa da mesma forma que você visualiza a localização do número da linha no seu código solidity. Se a função é definida na linha 59, você pode usar o número da linha como uma forma de dizer à máquina onde encontrar o código daquela função.
JUMPI significa “jump if”. Ele retira 2 valores da stack como input, o primeiro (59) é a localização do jump e o segundo (0) é o valor bool para o caso desse jump tiver que ser executado. Onde 1 = verdadeiro e 0 = falso.
Se é verdadeiro, o contador do programa será atualizado e a execução vai saltar para este local. No nosso caso, é falsa, o contador do programa não é alterado e a execução continua normalmente.
DUP1 de novo.
PUSH4 leva a assinatura de função de 4 bytes de store(uint256) (0x6057361d) para a call stack.
EQ de novo. Entretanto dessa vez o resultado é verdadeiro já que as assinaturas de função são iguais.
PUSH2, leva a localização do contador do programa para o bytecode do store(uint256), 0x0059 em hexadecimal que é igual a 89 em decimal.
JUMPI, nesse caso a verificação do bool é verdadeira, o que significa que o jump é executado. Isso atualiza o contador do programa para 89 que moverá a execução para uma parte diferente do bytecode.
Nesse local, haverá um opcode JUMPDEST, sem esse opcode no destino o JUMPI falhará.
Pronto, depois que esse opcode é executado, você será levado para o local do bytecode store(uint156) e a execução da função continuará normalmente.
Como esse contrato tinha somente 2 funções, os mesmos princípios se aplicam a um contrato com 20+ funções.
Agora você sabe como a EVM determina a localização do bytecode da função que ela necessita executar com base numa function call do contrato. É, de fato, apenas uma série de “if statements” para cada função no seu contrato junto com seus locais de jumps.
EVM Playground
Eu recomendo fortemente visitar esse link, é um EVM playground onde eu configurei o bytecode exato que acabamos de percorrer. Você poderá ver interativamente a mudança do stacking e eu incluí o JUMPDEST para que você possa ver o que acontece depois do JUMPI final.
O EVM playground também vai ajudá-lo a compreender o contador do programa, no código. Você verá os comentários ao lado de cada comando com seu deslocamento que representa o local do contador de programa.
Você poderá ver também o input do calldata à esquerda do botão Run. Tente mudá-lo para o retrieve() call data 0x2e64cec1 para ver como a execução muda. Basta clicar em Run e depois no botão “step into” (seta em curvas) acima à direita para saltar por cada opcode um por um.
Em seguida, na série, vamos viajar na estrada “Memory” em EVM Mergulhos Profundos - Parte 2.
Esse artigo pertence a uma série de três, foi publicado na Noxx e traduzido por Fátima Lima. Seu original pode ser lido aqui.
Top comments (0)