Foto de Nicholas Doherty em Unsplash
Os processos computacionais na blockchain Ethereum são muito caros. O uso de gás na execução de uma transação depende da complexidade da função que a inicia e da demanda por processamento de transações na blockchain.
Principalmente, é difícil estimar as taxas de taxa de gás, pois os mineradores decidem qual transação deve ser priorizada e incluída no próximo bloco à medida que a demanda aumenta. Os mineradores, portanto, priorizam transações que são mais lucrativas para eles (que têm um limite mais alto de uso de gás).
No entanto, esse sistema de execução em uma rede distribuída não é um bug, mas um recurso de descentralização — uma propriedade fundamental de uma rede blockchain.
Como desenvolvedor de contratos inteligentes, quanto mais complexo e computacionalmente intensivo for o seu contrato inteligente, mais gás será necessário para ser processado.
Ao escrever um contrato inteligente, é importante otimizar seus contratos inteligentes para reduzir o consumo de gás e criar uma melhor experiência para seus usuários.
Padrão para otimização de gás
Nas aplicações em produção, deve ser aparente que a otimização de gás não é apenas uma questão de truques inteligentes e novas expressões de dados; mas também, uma questão de tomada de decisão fundamental ao projetar a arquitetura de seus contratos inteligentes.
Assim como a lógica do contrato inteligente, seu sistema de otimização do uso de gás deve ser amplamente influenciado pela experiência que você deseja fornecer aos usuários do seu aplicativo.
Por exemplo, em um protocolo como a Uniswap, a função mais usada pelos usuários finais é a função de troca. Portanto, poderia ser mais apropriado ter essa função priorizada na otimização de gás. Essa mitigação pode exigir uma escolha que resultará em uma nova lógica sendo adicionada a outra chamada ou slot de armazenamento, resultando em maior uso de gás nessas chamadas.
Uso de gás na Ethereum
As operações mais caras na Ethereum (e na maioria das outras cadeias de blocos L1) geralmente envolvem armazenamento e busca de dados que devem persistir entre transações e blocos.
Como você sabe, seu código em Solidity precisa ser compilado no bytecode antes de ser implantado na rede Ethereum. Lá, a Máquina Virtual Ethereum (no original Ethereum Virtual Machine, EVM) define as instruções de tempo de execução para esse bytecode através da série EVM correspondente de códigos de operação. Ao interpretá-lo, ele atribui o espaço de armazenamento (que agora se tornou parte do estado da blockchain ) conforme a lógica do contrato inteligente presente no bytecode. Interações adicionais agora leem e escrevem para o estado.
Agora, armazenar e recuperar o estado é tão caro na Ethereum, porque o estado Ethereum reside no disco de cada nó, pois é muito grande para caber na memória, isso faz com que cada processo seja lento e exija um esforço para acompanhar a demanda.
A consequência disso é que as chamadas para acessar o estado agora se tornam caras. E é por isso que seu uso de gás é nativamente alto.
Portanto, voltando à otimização, fica claro que um dos principais objetivos das otimizações de gás deve ser minimizar o uso de armazenamento pelo contrato, pois isso pode levar a enormes economias para os usuários finais.
E isso nos levará à nossa primeira técnica de otimização de gás — A otimização do uso do armazenamento. Mas antes de mergulharmos, vamos dar uma olhada rápida em como a Ethereum usa gás durante a implantação do contrato.
Uso de gás durante a implantação
A criação de contrato envolve o uso de parâmetros que custam diferentes taxas de gás na EVM devido ao seu trabalho computacional. Para cada transação na Ethereum, há um custo de 21.000 gás. Além disso, o código de operação de criação de contrato (CREATE ) custa 32.000 gás. Depois, há um custo de 200 gás por byte cobrado por códigos escritos.
Agora, as regras de armazenamento da EVM envolvem a alocação de espaço de armazenamento em uma palavra (256 bits ou 32 bytes por slot).
contract Write {
uint256 slot0; //32 bytes slot
byte32 slot1;
}
// 6,400 de gás é cobrado por cada slot de LdC ( linha de códigos ).
Portanto, um slot custaria 6.400 (32 * 200). Se o tamanho do código do seu contrato inteligente for 1024 bytes (1kb), o custo de gás será de 204.800 (6.400 * 32).
Agora vamos adicionar isso com o gás da implantação e transação:
53.000 + 204.800 = 257.800 gás
Supondo que o preço atual do gás para o bloco atual seja de 40 gwei; o valor necessário para a transação a ser executada custaria 0,003144 ETH.
257.800 * 40 * 0,000000001 ETH ( 1 gwei ) = 0,010312 ETH
Isso é cerca de $18,69 nas taxas de conversão no momento da redação deste artigo ( 1,813 por 1 ETH ). (com esse preço podendo variar no futuro)
Portanto, otimizar o gás também pode economizar uma tonelada, pois o tamanho máximo do contrato é de 24kb. Deixada sem controle, uma implantação de contrato pode custar mais de $350 se os códigos de contrato atingirem o limite máximo e o preço de gás em alta.
Para chamadas de função após a implantação, além do custo inicial de 21.000 gás, a aprovação de dados em parâmetros em uma chamada de contrato custa 4 gás por valor igual a zero (0) e 16 gás para valores diferentes de zero. Como cada valor é medido em 1 byte.
É importante escrever códigos que tornem as transações mais baratas para os usuários. Vamos começar com o primeiro método de otimização:
1. Otimize o uso do armazenamento
Se você se lembra anteriormente, como mencionado, a EVM aloca armazenamento em slots de 256 bits. O número de slots de armazenamento e a maneira como você representa seus dados no seu Contrato Inteligente afetam fortemente o uso de gás.
Há algumas coisas que você pode fazer:
- Limitar o uso do armazenamento: Como o Armazenamento é o tipo mais caro de armazenamento de dados, você precisa usá-lo o mínimo possível, o que nem sempre é fácil. Use o Armazenamento apenas para os dados essenciais que você precisa para salvar na cadeia. O objetivo da blockchain como uma camada de execução para o seu aplicativo é fornecer descentralização e transparência de seu uso. Portanto, se sua lógica atingir o mesmo objetivo com um método mais barato, use-o. Por exemplo, dados transitórios (não permanentes) podem ser armazenados em Memória entre funções. Além disso, nas instruções da função, evite modificações desnecessárias no Armazenamento, salvando resultados intermediários na Memória ou em uma pilha. Atribua resultados a variáveis de armazenamento somente após a conclusão de todos os cálculos. Aqui está um trecho de código:
uint256 storageSlot;
address nextStorageSlot;
function fillStorage(uint256 amount) external returns(uint256){
//caches de variável de armazenamento na memória que usa os códigos de operação SLOAD e MSTORE EVM sob o capô para ler do armazenamento e gravar na memória, respectivamente
//Custa 800 gás para ler no armazenamento e gás para armazenar na memória
// fonte: [https://ethereum.stackexchange.com/questions/90318/does-an-sstore-where-the-new-value-is-the-same-as-the-existing-value-cost-gas](https://ethereum.stackexchange.com/questions/90318/does-an-sstore-where-the-new-value-is-the-same-as-the-existing-value-cost-gas)
uint256 memorySlot = storageSlot;
//faça um cálculo e atribua o resultado ao armazenamento
//atribuir ao armazenamento usa os códigos de operação MLOAD e SSTORE EVM sob o capô para ler da memória e gravar no armazenamento, respectivamente – Custa 3 gás para carregar da memória em cada chamada (contrária ao custo de 100 gás por leitura do armazenamento) e 20000 gás para gravar no armazenamento
//NOTA: Os operandos de cálculo adicionais consumirão mais gás, mas estão fora de escopo aqui
return storageSlot = (memorySlot/amount) + (memorySlot * amount);
}
Usar eventos: Emitir eventos em vez do uso do armazenamento é uma boa maneira de otimizar o gás, pois é mais barato consultar eventos fora da cadeia do que buscar dados no armazenamento.
Variáveis de embalagem em uma estrutura: A quantidade mínima de memória de armazenamento na Ethereum é um slot de 256 bits. Mesmo que os slots não estejam cheios, você precisa pagar por uma quantidade inteira para concluir cada palavra (slot de 256 bits). Portanto, espaços vazios são preenchidos com zeros para encher as 256 unidades de bits. Para evitar isso, você pode usar a técnica de embalagem variável. O compilador empacotará várias variáveis consecutivas cujo comprimento total é 256 ou menos em um único slot. Use esta técnica ao definir estruturas também. Além disso, declare as variáveis de armazenamento do mesmo tipo de dados consecutivamente ao defini-las. Portanto, o compilador Solidity pode embalá-los eficientemente no mesmo slot:
struct Slot0 {
// o preço atual
uint160 sqrtPriceX96;
// o thick atual
int24 tick;
// o índice atualizado mais recentemente da matriz de observações
uint16 observationIndex;
// o número máximo atual de observações que estão sendo armazenadas
uint16 observationCardinality;
// o próximo número máximo de observações a serem armazenadas acionado em observações.w rito
uint16 observationCardinalityNext;
// a taxa atual do protocolo como uma porcentagem da taxa de troca retirada
// representado como um denominador inteiro (1 / x )%
uint8 feeProtocol;
// se a pool está trancada
bool unlocked;
}
Slot0 public slot0;
Um trecho de código da UniswapV3
No entanto, a embalagem variável se aplica apenas a variáveis de armazenamento e não a variáveis Memory e Calldata que estão sendo declaradas em funções.
Uint * vs. Uint256: A EVM realiza operações em pedaços de 256 bits, portanto, converter um número inteiro não assinado uint * (menor que 256 bits ) para uint256 consome gás adicional durante a consulta. Ao empacotar mais variáveis em um único slot, use números inteiros não assinados menores ou iguais a 128 bits. Se isso não for possível, use variáveis uint256. Isso se aplica a outros tipos de dados — bools, int * (números menores que 256 bits), endereço, bytes * ( bytes menores que 32 bytes / 256 bits ) e cadeias incorrem em sobrecarga quando usado sozinho.
Mapas vs. matriz: O armazenamento de uma lista de dados só pode ser feito com dois tipos de dados em Solidity: matrizes e mapas. As matrizes são iteráveis porque possuem slots de armazenamento sequencial com tamanhos apropriados, enquanto os mapeamentos não são — são escassamente armazenados e só podem ser recuperados com suas chaves. Porém, os mapeamentos são mais eficientes e menos caros, sendo uma boa ideia usar mapeamentos para gerenciar listas de dados, a menos que você queira iterar ou compactar os tipos de dados. No entanto, se você estiver gerenciando uma lista ordenada, ainda é possível usar o mapeamento fornecendo um índice inteiro como chave. Isso funciona tanto no armazenamento quanto na memória.
//declaração
uint256 index;
// um mapeamento de endereço ordenado, usando o índice como a chave
mapping(uint256 => address) addressMap;
//chaves de mapeamento – implementadas principalmente entre funções
addressMap[index] = //valor;
++index;
//iterando o mapa
for(uint256 i; i < index; ++i){
addressMap[index] = //action;
}
Matriz de tamanho fixo: Qualquer variável de tamanho fixo no Solidity é menos cara que uma variável. Use uma matriz de tamanho fixo em vez de dinâmica, quando possível.
Valor padrão: Quando as variáveis são inicializadas, é uma boa prática definir seus valores, mas isso usa gás. Em Solidity, todas as variáveis são definidas como 0 por padrão. Portanto, se o valor padrão de uma variável for 0, não a defina explicitamente para esse valor.
Uso de constantes / imutáveis: As variáveis de estado que nunca mudam devem ser declaradas como imutáveis ou constantes.
2. Otimização do uso de dados de memória
Calldata vs Memória: Usar calldata em vez de memória para argumentos somente leitura em funções externas economiza gás. Por exemplo, quando uma função com uma matriz de memória é chamada externamente, a etapa abi.decode() no nível evm deve usar um for-loop para copiar cada índice dos calldata para o índice de memória. Cada iteração deste loop custa pelo menos 60 gás ( i.e. 60 * < mem_array >. length). O uso de calldata elimina diretamente a necessidade de tal loop no código do contrato e na execução do tempo de execução. Mesmo que uma interface defina uma função como tendo argumentos de memória, ainda é válido que os contratos de implementação usem argumentos de calldata.
Arranjos de limite no argumento de funções: Permitir matrizes dinâmicas pode levar à negação de serviço e pode levar à transação falha. Quando itens em uma matriz excedem os recursos computacionais disponíveis, você arrisca ficar dentro do limite de gás do bloco, bloquear o enchimento e negação de serviço. Use matrizes fixas em argumentos de função ou limite itens de matriz através da técnica de efeitos de verificação.
Use revert( ) para verificar os efeitos: Usar revert em vez de require permite que você adote erros customizados que usam menos gás do que as declarações de require usadas para as mensagens de erro que são strings. E strings são tipos de dados caros porque são convertidos em bytes antes do armazenamento. Para declarações require, declarar erros personalizados como números inteiros com valores atribuídos é outra maneira de otimizar o uso de gás.
3. Otimizar operações
Conforme indicado no início deste artigo, nem todas as implementações de uma solução são igualmente eficientes em termos de uso de gás. A maneira como você decide implementar uma chamada de função ou uma expressão lógica complexa pode contribuir para a quantidade de gás usada. Aqui estão alguns casos em que uma solução mais ideal pode reduzir o gás usado:
Chamadas de função interna: Quando você chama uma função pública, leva muito mais tempo do que chamar uma função interna, pois todos os parâmetros são copiados na Memória. Em vez disso, use chamadas de função internas, onde os argumentos são passados como referências, sempre que possível. Eles são um pouco mais caros de executar, mas economizam muito bytecode duplicado quando usados várias vezes.
Menos funções: Tente reduzir ao mínimo o número de funções internas e privadas para equilibrar a complexidade e a quantidade da função. Por sua vez, isso ajudará você a reduzir as taxas de gás após a execução, reduzindo o número de chamadas de função. No entanto, lembre-se de que ter funções muito grandes dificultam o teste e até prejudicam a segurança. Portanto, tenha cuidado para não reduzir muito o número de funções.
Curto-circuito: Quando se trata de expressões lógicas, tente simplificar as complexas o máximo possível. Escreva-as para minimizar as chances de avaliação desnecessária da segunda expressão. Lembre-se de que quando a primeira expressão é verdadeira em uma disjunção lógica (OU / | |), a segunda não será executada. Além disso, lembre-se de que, se a primeira expressão for avaliada como falsa em uma disjunção lógica ( E / & ), as expressões subsequentes não serão avaliadas.
Limitando modificadores: O código dos modificadores é colocado em uma função modificada, o que aumenta seu tamanho e uso de gás. Para evitar isso, reduza o número de modificadores e troque por técnicas de efeitos de verificação.
Troca de linha única: Em uma instrução, você pode trocar os valores de duas variáveis. Use: ( a, b ) = ( b, a ) em vez de usar uma variável auxiliar para troca.
4. Explore algumas recomendações gerais
Além de implementar algumas soluções específicas, você também pode tentar evitar algumas práticas que podem contribuir para taxas mais altas de gás:
Minimizar o acesso direto às variáveis de armazenamento: Tente acessar variáveis de armazenamento mediante variáveis locais (Memória e Calldata). Em vez de ler / gravar repetidamente em uma variável de armazenamento, copie-a para uma variável local e use-a. Subscreva para a variável Armazenamento somente quando o resultado for calculado.
Use loops o mínimo possível: Use loops no seu contrato inteligente somente se necessário e inevitável.
Comprimento da matriz de cache durante loops
//declarar uma matriz dinâmica
uint256[] array;
//custa mais gás
for(uint256 i; i > array.length; i++){
}
//custo menos gás
uint256 arrayNum = array.length;
for(uint256 i; i > arrayNum; i++){
}
Eliminar cálculos desnecessários: Tente encontrar os algoritmos mais eficientes para executar cálculos. Remova cálculos se o seu algoritmo usar diretamente os resultados deles. Em outras palavras, elimine cálculos não utilizados.
Estruturas de dados ineficientes: Tente encontrar estruturas mais eficientes e adequadas para representar seus dados. Por exemplo, favoreça mapeamentos em vez de matrizes para operações de indexação direta se você não precisar iterar os dados em sequência.
5. Use soluções que demandem menos energia
Existem algumas outras opções que podem ajudá-lo a otimizar contratos inteligentes e reduzir o consumo de gás. Considere usar:
Contratos pré-compilados, quando disponíveis.
Bibliotecas otimizadas. Tente bibliotecas openzeppelin ou solmate.
Estruturas de dados compressíveis. Por exemplo, salvar uma estrutura em um mapeamento com o índice uint256 como uma chave.
ZK-SNARKs para reduzir a quantidade de dados que precisam ser armazenados e calculados na cadeia.
Usar bitmap em vez de uma variedade de listas booleanas.
Nota: Este artigo é fortemente influenciado pelo Blog do Tenderly na otimização de gás. Pode ser atualizado no futuro com novas dicas de otimização. Fique atento.
Referências
https://blog.tenderly.co/how-to-reduce-smart-contract-gas-usage/
https://twitter.com/chrisdior777/status/1612773558660304897?t=hfW1Ath8RD5fos2hNWeS-Q&s=19
https://consensys.github.io/smart-contract-best-practices/attacks/denial-of-service/
https://en.wikipedia.org/wiki/Word_(computer_architecture)
https://coinsbench.com/gas-optimizations-in-smart-contracts-a894768b274c
https://docs.uniswap.org/blog/intro-to-gas-optimization
https://soliditydeveloper.com/bitmaps
Este artigo foi escrito por Sheys e traduzido por Adriano P. de Araujo. O original em inglês pode ser encontrado aqui.
Oldest comments (0)