E como não repeti-los novamente
Artigo original: [(https://medium.com/coinmonks/10-evm-design-mistakes-7c9a75a77ee9)]
Autor: Mikhail Vladimirov
Traduzido por: Marcelo Creimer
10 Dez, 2020
A plataforma blockchain Ethereum fez realmente grandes progressos desde seu começo há 5 anos atrás. Ela se tornou o berço e a casa para muitas coisas incríveis, como DAOs, ICOs, DeFi, etc.
A EVM (Máquina Virtual Ethereum - Ethereum Virtual Machine) está no cerne do ecossistema Ethereum. É um computador distribuído que executa smart contracts, garantindo que o código é a lei. Entretanto, a EVM por ela mesma sofre alguns problemas de longa data originados de erros de desenho nos estágios iniciais. Como um desenvolvedor profissional de smart contract e auditor, eu vejo as consequências destes erros repetidamente.
Aqui está a minha coleção de erros de desenho da EVM.
1. SELFDESTRUCT (SUICIDE)
A EVM tem o opcode SELFDESTRUCT
(antigamente conhecido como SUICIDE
), que basicamente elimina o contrato que executou o opcode SELFDESTRUCT
. Isto significa que o bytecode do contrato e seu armazenamento são apagados, e o saldo de ether do contrato é transferido para o endereço fornecido como parâmetro ao opcode SELFDESTRUCT
.
A ideia provavelmente foi eliminar o state do blockchain dos contratos que não são mais necessários. Entretanto, ele não funciona desta maneira, já que um contrato particular pode somente ser destruído por ele mesmo, e normalmente, um contrato não sabe se ele ainda é necessário ou não.
Por isso, o problema com o opcode SELFDESTRUCT
não é que ele não faça o que deveria fazer, mas ao invés disso ele quebra dois importantes princípios do Ethereum: ele faz o bytecode do contrato mutável (apagar o bytecode significa alterá-lo) e faz possível enviar ether para um contrato sem executar seu bytecode.
Lembra-se do Parity Multi-Sig Wallet Self-Destruct (Carteira multi-assinatura autodestrutiva do Parity)? Pessoas perdem acesso a ativos de criptomoedas com valores de milhões de dólares por causa de um simples smart contract destruído por ele mesmo.
Apesar de não ser possível remover o SELFDESTRUCT
da EVM, é possível desencorajar seu uso e tornar a função correspondente em Solidity como obsoleta.
2. Palavras de Largura Limitada
A EVM é uma máquina virtual 256-bit, o que significa que ela opera com largura fixa de palavras 256-bit. Quando o resultado da operação não cabe dentro de 256 bits, os bits mais altos são simplesmente abandonados, o que é conhecido como overflow. Esta é uma situação perigosa, já que a operação pode produzir um resultado que é matematicamente incorreto.
Para arquiteturas de hardware, palavras de largura fixa que podem sofrer overflow é uma decisão natural, já que operações em tais palavras poderiam ser implementadas com um número fixo de portões lógicos e executadas em um número constante de ticks. Entretanto, arquiteturas de hardware geralmente fornecem alguma maneira de saber se o overflow aconteceu mesmo, e mesmo obter bits extras que não couberam na palavra resultante.
Para linguagens de programação de baixo nível, tipos de dados de largura fixa que podem sofrer overflow é uma decisão natural porque eles mapeiam 1:1 com as palavras do hardware subjacente, fornecendo assim máximo desempenho.
Entretanto, como as principais arquiteturas hoje em dia são somente 64-bit, as palavras de 256-bit da EVM não mapeiam com as palavras do hardware, e assim não têm como serem implementadas como big integers (inteiros grandes), ou seja, palavras emuladas por software de largura arbitrária.
Levando isso em conta, a decisão natural para a EVM seria usar largura arbitrária de palavras que não sofrem overflow, ao invés das de tamanho fixo, que sofrem overflow. Isso eliminaria o infame problema de overflow, assim como faria muitas coisas mais simples.
Apesar de não ser possível agora mudar a arquitetura das palavras de tamanho fixo para tamanho arbitrário, ainda é possível introduzir smart contracts pré-compilados para operações eficientes de big-integer (inteiros grandes).
3. Pilha Muito Profunda
Geralmente os opcodes da EVM pegam os argumentos do topo da pilha (stack) e põem o resultado de volta lá. Entretanto, há opcodes como DUP1
, DUP2
, …, DUP16
, e SWAP1
, SWAP2
, …, SWAP16
que permitem acessar elementos mais profundos da pilha. Mas, esse acesso randômico à pilha é limitado somente aos 16 elementos mais de cima.
Esta limitação faz o Solidity exibir o infame erro “Stack Too Deep” (Pilha Muito Profunda) quando o valor a ser acessado é muito profundo na pilha. Cabe ao compilador Solidity como arranjar valores na pilha, de modo que é impossível prever quando este erro vai aparecer da próxima vez. Uma recomendação comum é apenas não usar demais variáveis locais e argumentos de funções, o que é ridículo. Isto faz com que a precisão de um programa Solidity dependa das técnicas de otimização usadas pelo compilador.
É possível consertar este problema introduzindo os opcodes genéricos DUP
e SWAP
que pegariam a profundidade de slot da pilha como um parâmetro normal.
4. Espaços de Memória Separados
A memória da EVM é separada em diversos espaços, sendo acessada via diferentes opcodes. Estes espaços são: i) memória normal, acessada via MLOAD
, MSTORE
, e MSTORE8
; ii) stack, acessada via DUP<n>
, SWAP<n>
, e vários outros opcodes, iii) call data, acessada via CALLDATALOAD
, CALLDATASIZE
, e CALLDATACOPY
; iv) return data, acessada via RETURNDATASIZE
e RETURNDATACOPY
; v) byte code, acessada via CODESIZE
e CODECOPY
.
Enquanto todas estas memórias usarem diferentes opcodes, não é possível ter ponteiros genéricos estilo C na EVM, que pudessem apontar para qualquer dado, independente do tipo de memória que está guardada lá. O Solidity tenta endereçar isto introduzindo diferentes tipos de ponteiros, como “memory” e “calldata”, mas ele ainda não suporta ponteiros “stack”, “code”, ou “returndata”. Além disso, esta abordagem não resolve o problema completamente, já que estes ponteiros não são conversíveis uns nos outros. Se uma função aceita ponteiro “memory”, mas nós precisamos passar para ela alguns dados armazenados como literal no código, ou apenas retornado a nós por uma chamada externa e ainda residindo no dado de retorno, ou passar para o nosso contrato vindo de fora e estando no calldata, ou apenas armazenado em uma variável local no stack, então nós precisamos primeiro alocar memória e copiar nossos dados nesta região, o que não é o ideal.
5. CREATE2
O opcode CREATE2
foi introduzido recentemente, com a finalidade de permitir que um contrato reserve endereço para os futuros contratos filhos, mas somente criando estes contratos filhos quando necessário.
A ideia era que este byte code do contrato filho tem de ser conhecido previamente para que se possa reservar o endereço, mas estes contratos filhos se tornam imutáveis mesmo antes de serem criados. Infelizmente, junto com os já existentes opcodes CREATE
e SELFDESTRUCT
, este novo opcode CREATE2
tornasse possível substituir byte code de um contrato deployed (implantado) por um novo e arbitrário byte code, enquanto preservando o endereço do contrato. Isto quebra o princípio do Ethereum de imutabilidade do contrato.
Em alguns casos é até mais barato armazenar e atualizar dados em um byte code de um contrato, ao invés do armazenamento no contrato.
6. Erros Aritméticos Silenciosos
Por erros aritméticos aqui nós queremos dizer situações onde uma operação produz resultado matemático incorreto, ou seja, overflows, underflows, e divisões por zero que retornam zero na EVM.
As principais arquiteturas de hardware oferecem algumas maneiras de saber se a operação retornou resultado matematicamente incorreto, ou não, mas a EVM não fornece essa funcionalidade. Isto torna difícil e caro em termos de gas fazer matemática de modo seguro.
O problema poderia ser resolvido introduzindo um novo opcode que iria obter _flags _do status do último opcode executado.
7. Byte Code Imutável
O byte code do contrato é imutável. Este é um dos princípios do Ethereum, infelizmente, quebrado pelo opcode SELFDESTRUCT
, e ainda mais quebrado pela combinação dos opcodes CREATE2
+ CREATE
+ SELFDESTRUCT
. Mas pelo menos para contratos que não usam SELFDESTRUCT
, o byte code armazenado on-chain é imutável. Isto significa que podemos estudar o byte code antes de interagir com o contrato, e ter certeza que o código não mudará entre o tempo em que ele foi estudado e o que foi feita a interação.
Entretanto, em alguns casos, seria mais conveniente para um contrato modificar seu próprio byte code em memória em tempo de execução, sem salvar estas modificações on-chain. Estas modificações não quebrariam o princípio da imutabilidade, já que código armazenado no blockchain irá permanecer inalterado, mas tornará possível para os contratos modificarem seu código ou ainda gerar novo código on the fly baseado nas entradas, e assim reduzindo custo com gas.
Atualmente, a EVM proíbe isso, mas ainda é possível que novos opcodes tornem isso possível.
8. Falta de Extensibilidade
Há duas maneiras comuns como uma nova funcionalidade pode ser adicionada à EVM: introduzindo novos opcodes e introduzindo novos contratos pré-complidados. Não existem regras estritas sobre que método utilizar para cada caso em particular. O hash Keccak256 é implementado como um opcode (SHA3
), enquanto as funções hash SHA256 e o HIP160 são implementadas como contratos pré-compilados. Ambas maneiras requerem hard forks e ambas maneiras não são 100% backward compatible (compatíveis com a versão anterior), já que elas podem afetar o comportamento de contratos já implantados.
Além disso, os opcodes de baixo nível, que operam puramente dentro da EMV, ou seja, afetando apenas a pilha, memória e o ponteiro de instrução, são misturados com opcodes de alto nível que lidam com o state do blockchain.
A EVM deveria ter uma maneira própria de adicionar novos recursos como um opcode especial SYS
utilizado para realizar uma chamada de sistema, ou seja, chamar alguma funcionalidade externa. Ela também deveria claramente separar opcodes de baixo nível, que são independentes da estrutura de state do blockchain, e chamadas de sistema de alto nível, que interagem com este state.
9. Palavras Muito Largas
Embora o problema de palavras de largura limitada já tenha sido mencionado, o outro problema é que este limite de largura é… muito largo. Palavras de largura 256-bit são muito mais largas que palavras de 64-bit nativamente suportadas pelos principais hardwares. Isto torna caro operações com estas palavras, mesmo que seus valores reais sejam pequenos. Lidar com booleans, chars, índices de array, e outros pequenos números consome bastante gas: na verdade o mesmo que seria consumido no caso do número ser grande.
Uma maneira de consertar isso seria ter opcodes que operam em menos de 64 ou mesmo 32 bits dos argumentos.
10. Sem Acesso Ao Armazenamento de Outros Contratos
No Ethereum, o armazenamento do contrato é informação pública, mas não para os outros contratos. Quando um contrato não precisa fornecer uma função _getter _para algumas informações valiosas mantidas no armazenamento do contrato, ainda é possível ler esta informação off-chain. Só é preciso descobrir o endereço do armazenamento. Mas os outros contratos não podem fazer isso. Atualmente, autores de contratos tentam declarar quase toda variável de _storage _como pública, caso seu valor seja eventualmente necessário em outros contratos, já que não é possível tornar a variável pública depois que o contrato for _deployed _(implantado).
O problema poderia ser facilmente resolvido pela introdução do opcode EXTSLOAD
, que lê de um storage de outro contrato.
Os problemas descritos acima são o que eu coletei desenvolvendo e auditando smart contracts do Ethereum por cerca de 4 anos. Estes problemas não são fatais e podem ser contornados, entretanto, a maioria deles poderia ser consertada facilmente, e pelo menos alguns deles valem realmente a pena serem consertados.
Se você souber de algo que deva ser adicionado a esta lista, por favor me avise.
Latest comments (0)