Este artigo é o segundo de uma série de artigos sobre matemática em Solidity. Desta vez o tópico é: overflow.
Tradução de Mikhail Vladimirov feita por Marcelo Creimer, versão original disponível nesse link.
Introdução
Toda vez que eu vejo +
, *
, ou **
, fazendo auditoria em um smart contract em Solidity, eu começo escrevendo o seguinte comentário: “overflow é possível aqui”. Eu preciso de alguns segundos para escrever estas quatro palavras, e durante estes segundos eu observo as linhas próximas tentando encontrar uma razão, porque o overflow não é possível, ou porque o overflow deveria ser permitido neste caso em particular. Se a razão for encontrada, eu apago meu comentário, mas o mais frequente é o comentário continuar no relatório final de auditoria.
As coisas não deveriam ser deste modo. Operadores aritméticos deveriam permitir escrita compacta e leitura fácil para fórmulas como a**2 + 2*a*b + b**2
. Entretanto, esta expressão iria quase que definitivamente levantar diversas questões de segurança, e o código real é mais provável se parecer com isso:
add (add (pow (a, 2), mul (mul (2, a), b)), pow (b, 2))
Aqui add
, mul
, e pow
são funções implementando versões “seguras” de +
, *
, e **
respectivamente.
Sintaxe concisa e conveniente é desencorajada, simples operadores aritméticos são marginalmente utilizados (e não mais que um por vez), sintaxe funcional incômoda e ilegível está em toda parte. Neste artigo nós analisamos o problema que torna as coisas tão estranhas, cujo infame nome é: overflow.
Nós Pegamos a Curva Errada em Algum Lugar
Alguém diria que o overflow sempre esteve lá, e todas as linguagens de programação sofrem com isso. Mas isso é realmente verdade? Você já viu alguma coisa como a biblioteca SafeMath implementada para C++, Python, ou JavaScript? Você realmente pensa que cada + ou * é uma violação de segurança, até que se prove o contrário? Provavelmente, suas respostas a ambas as perguntas é “não”. Então,
Porque Overflow em Solidity é Tão Mais Doloroso?
Spoiler: nenhum lugar para correr, nenhum lugar para se esconder.
Números não sofrem overflow na matemática pura. Uma pessoa pode somar dois números longos arbitrários e obter um resultado preciso. Números não sofrem overflow em linguagens de programação de alto nível como JavaScript e Python. Em alguns casos o resultado poderia atingir o infinito, mas pelo menos adicionar dois números positivos nunca poderá produzir resultado negativo. Em C++ e Java, números inteiros realmente sofrem overflow, mas números de ponto flutuante, não.
Nestas linguagens, onde tipos inteiros sofrem overflow, inteiros simples são usados primariamente para índices, contadores, e tamanhos de buffer, isto é, para valores limitados pelo tamanho do dado sendo processado. Para valores, que potencialmente possam exceder a faixa de inteiros simples, existem os tipos de dado ponto-flutuante, inteiros grandes, grandes decimais, ou embutidos ou implementados via bibliotecas.
Basicamente, quando o resultado de uma operação aritmética não se encaixa dentro dos tipos dos argumentos, há umas poucas opções do que o compilador pode fazer:
i) usar um tipo mais longo para o resultado;
ii) retornar o resultado truncado e usar um canal paralelo para notificar o programa sobre o overflow;
iii) lançar uma exceção; e
iv) apenas retornar silenciosamente o resultado truncado.
A primeira opção é implementada no Python 2 quando manipulando overflows do tipo inteiro
. A segunda opção é para o que servem as flags carry/overflow da CPU. A terceira opção é implementada em Solidity pela biblioteca SafeMath. A quarta opção é o que o Solidity implementa por si só.
A quarta opção é provavelmente a pior, já que ela faz operações aritméticas propensas a erro, e ao mesmo tempo torna a detecção de overflow bastante cara, especialmente para o caso da multiplicação. É preciso realizar uma divisão adicional depois de cada multiplicação por medida de segurança.
Portanto, o Solidity nem tem tipos seguros, que alguém poderia usar, nem tem operações seguras, onde alguém poderia apoiar-se. Não tendo lugar para fugir nem para se esconder, os desenvolvedores têm que encontrar overflows cara a cara e lutar contra eles através de todo o código.
Então, a próxima questão é:
Por que o Solidity Não tem Tipos Seguros Nem Operações Seguras?
Spoiler: porque a EVM não os tem.
Smart contracts têm de ser seguros. Bugs e vulnerabilidades neles custam milhões de dólares, conforme já aprendemos de modo difícil. Sendo a linguagem primária para desenvolvimento de smart contract, o Solidity leva a segurança muito a sério. Ele tem muitos recursos que deveriam prevenir os desenvolvedores de darem um tiro no próprio pé. Recursos como a palavra-chave payable, limitações de cast de tipos, etc. Esses tipos de recursos são adicionados a cada nova versão, frequentemente quebrando a compatibilidade com versões anteriores, mas a comunidade tolera isso pelo bem da segurança.
Entretanto, operações aritméticas básicas são tão inseguras que quase ninguém as usa diretamente hoje em dia, e a situação não melhora. A única operação que se tornou um pouco mais segura é a divisão: divisão por zero costumava retornar zero, mas agora ele lança uma exceção; mas nem a divisão se tornou totalmente segura, pois ela ainda pode gerar overflow. Sim, em Solidity, divisões com o tipo int sofrem overflow quando -2¹²⁷ está sendo dividido por -1, já que a resposta correta (2¹²⁷) não cabe no int. Todas as outras operações, como +
, -
, *
, e **
ainda estão sujeitas a overflow ou underflow, e, portanto, são intrinsecamente inseguras.
Operações aritméticas em Solidity replicam o comportamento dos correspondentes opcodes da EVM, e fazer estas operações seguras ao nível do compilador iria aumentar o consumo de gas diversas vezes. Um simples opcode_ADD custa 3 gas. A sequência de opcode mais barata para implementar seguramente o ADD, que o autor deste artigo conseguiu encontrar é:
DUP2(3) DUP2(3) NOT(3) LT(3) <overflow>(3) JUMPI(10) ADD(3)
Aqui <overflow> é o endereço para saltar em caso de overflow. Números em colchetes são os custos de gas das operações, e estes números nos dão 28 gas no total. Quase 10 vezes mais que um simples ADD. Demais, não? Depende de com o que você compara. Digamos, chamar a função add da biblioteca SafeMath custaria cerca de 88 gas.
Portanto, aritmética segura no nível da biblioteca ou do compilador custa muito, mas
Por que a EVM Não Tem Opcodes Aritméticos Seguros?
Spoiler: por nenhuma boa razão.
Alguém poderia dizer que a semântica aritmética da EVM replica a da CPU por razões de desempenho. Sim, algumas CPUs modernas têm opcodes para aritmética de 256-bit, entretanto as principais implementações de EVM não parecem utilizar estes opcodes. O Geth usa o tipo big.Int da biblioteca padrão da linguagem de programação Go. Este tipo implementa arbitrariamente inteiros largos grandes apoiados por arrays de palavras nativas. O Parity usa sua própria biblioteca implementando inteiros grandes de largura fixa sobre as palavras 64-bit nativas.
Para ambas as implementações, o custo adicional da detecção de overflow aritmético seria virtualmente zero. Desse modo, uma vez que a EVM tivesse versões de opcodes aritméticos, que reverte em overflow, seu custo de gas poderia ser o mesmo das versões inseguras existentes, ou apenas marginalmente maior.
Mais útil ainda seriam opcodes que simplesmente não tem overflow, retornando o resultado inteiro por sua vez. Estes opcodes permitiriam implementação eficiente de grandes inteiros largos arbitrários no nível do compilador ou biblioteca.
Nós não sabemos por que a EVM não tem os opcodes descritos acima. Talvez apenas porque outra das principais máquinas virtuais também não os tem?
Por enquanto nós estamos falando sobre overflow real; uma situação em que o resultado do cálculo é grande demais para caber no tipo de dado de resultado. Agora é hora de descobrir o outro lado do problema:
Overflow Fantasma
Como alguém calcularia 3% de x em Solidity? Nas principais linguagens pode-se escrever 0.03*x, mas o Solidity não suporta frações. E que tal x*3/100? Bem, isso irá funcionar na maioria dos casos, mas e se x é tão largo que x*3 vai gerar overflow? Da seção anterior nós sabemos o que fazer, correto? Apenas use mul da SafeMath e fique com segurança: mul (x, 3) / 100… Não tão rápido.
A última versão é um tanto mais segura, pois ela reverte onde a primeira versão retorna resultado incorreto. Isso é bom, mas... Por que nesse mundo calcular 3% de alguma coisa pode gerar overflow? 3% de alguma coisa é garantidamente menos que o valor original: em ambos, termos nominal e absoluto. Portanto, enquanto x cabe em uma palavra 256-bit, então 3% de x deveria também caber, não deveria?
Bem, eu chamo isso de “overflow fantasma”: uma situação quando o resultado final do cálculo caberia no tipo de dado, mas alguma operação intermediária sofre overflow.
Overflows fantasmas são muito mais difíceis de detectar e endereçar que os overflows reais. Uma solução é utilizar tipos de inteiro mais largos ou mesmo um tipo de ponto-flutuante para valores intermediários. Uma outra é refatorar a expressão para que o overflow fantasma seja impossível. Vamos tentar fazer o último com a nossa expressão.
As leis da aritmética nos contam que as seguintes fórmulas deveriam produzir o mesmo resultado:
(x * 3) / 100
(3 * x) / 100
(x / 100) * 3
(3 / 100) * x
Entretanto, divisão de inteiros em Solidity não é o mesmo que divisão na matemática pura, já que em Solidity ele arredonda o resultado em direção a zero. As primeiras duas variantes são basicamente equivalentes, e ambas sofrem do overflow fantasma. A terceira variante não tem problema de overflow fantasma, mas é de alguma maneira menos precisa, especialmente para um x pequeno. A quarta variante é mais interessante, já que ela surpreendentemente leva a um erro de compilação:
browser/Junk.sol:5:18: TypeError: Operator * not compatible with types rational_const 3 / 10 and uint256browser/Junk.sol:5:18: TypeError: Operator * not compatible with types rational_const 3 / 10 and uint256
Nós já descrevemos este comportamento no nosso artigo anterior. Para fazer a quarta expressão compilar, nós precisamos alterá-la deste modo:
(uint (3) / 100) * x
Entretanto, isto não ajuda muito, já que o resultado da expressão corrigida é sempre zero, porque 3 / 100 arredondado em direção a zero é zero.
Através da terceira variante nós conseguimos resolver o problema do overflow fantasma à custa de precisão. Na verdade, a perda de precisão é significante apenas para x _pequenos, enquanto para grandes _x ela é insignificante. Lembre-se que para a expressão original o problema do overflow fantasma cresce somente para grandes x, então parece que nós devemos combinar ambas variantes, assim:
x > UM_NUMERO_GRANDE ? x / 100 * 3 : x * 3 / 100
Aqui UM_NUMERO_GRANDE poderia ser calculado como (2²⁵⁶-1)/3 e arredondar este valor para baixo. Agora para um x pequeno nós usamos a fórmula original, enquanto para um x grande nós usamos a fórmula modificada que não permite o overflow fantasma. Parece que nós resolvemos o problema do overflow fantasma sem perda significante de precisão agora. Bom trabalho, certo?
Neste caso particular, provavelmente sim. Mas e se nós precisarmos calcular não 3%, mas sim 3.1415926535%? A fórmula seria:
x > UM_NUMERO_GRANDE ?
x / 1000000000000 * 31415926535 :
x * 31415926535 / 1000000000000
Nosso UM_NUMERO_GRANDE se tornará (2²⁵⁶-1)/31415926535. Não tão largo então. E sobre 3.141592653589793238462643383279%? Sendo boa para casos simples, esta abordagem não parece escalar bem.
Conclusão
A EVM não oferece opcodes anti-overflow. O Solidity não oferece nenhuma proteção ao nível do compilador. Desse modo, desenvolvedores de smart contract têm que endereçar overflows ao nível do código, o que torna códigos incômodos e menos eficiente em termos de gas.
Overflows fantasma são ainda mais difíceis de detectar e endereçar. Acabam levando para trocas e frequentemente não escalam.
No nosso próximo artigo, nós vamos sugerir melhores abordagens sobre o problema do overflow fantasma, e o tópico do nosso próximo artigo será: percentuais e proporções.
Outros artigos nesta série:
· Parte 1: Números
· Parte 3: Percentuais e Proporções
· Part 4: Juros Compostos
· Part 5: Expoente e Logaritmo
Latest comments (0)