Como atualizar Smart Contracts em Solidity (Proxy)

Cripto

Como atualizar Smart Contracts em Solidity (Proxy)

Luiz Duarte
Escrito por Luiz Duarte em 09/04/2024
Junte-se a mais de 34 mil devs

Entre para minha lista e receba conteúdos exclusivos e com prioridade

Quando aprendemos os fundamentos da blockchain, que você confere neste artigo aqui, uma das primeiras coisas que aprendemos é que ela é imutável. Isso não impede de novas informações serem adicionadas, mas sim de que informações anteriores não podem ser alteradas, o histórico se mantém eterno. Da mesma forma, quando estamos programando para uma blockchain, os nossos algoritmos/programas que chamamos de Smart Contracts (que você aprende o básico aqui) também não podem ser alterados, apenas podemos publicar novos smart contracts, com novos endereços.

Isso quer dizer que você pode adicionar novos smart contracts na blockchain, incluindo novas versões do mesmo, mas que jamais poderá excluir ou modificar um já existente. E essa frase coloca muito medo nos programadores Solidity pois caso você suba uma versão do contrato com um bug ou vulnerabilidade, isso quer dizer que ela ficará na blockchain para sempre. E sim, isso é verdade.

Apesar disso, claro, existem medidas de contorno que você pode fazer. Uma delas seria avisar todo mundo que usa este smart contract bugado a desconsiderá-lo e passar a usarem somente o novo que você publicou com o bug corrigido. Mas nem sempre isso é viável ou desejável. O ideal é que você, já na concepção do smart contract, crie algum mecanismo que permita a atualização do seu smart contract posteriormente.

#1 – Opções de Atualização

A opção número um é a que citei antes: desconsidere o endereço antigo do contrato e passe a usar somente o novo. Ponto. Simples e direto mas que geralmente causa muitos problemas se seu smart contract é de uso público como uma DEX ou é uma criptomoeda ERC-20 com vários holders. Se for o segundo caso, ainda dá para contornar: você pode publicar um contrato de migração para fornecer tokens da nova moeda na mesma quantidade que os holders já possuíam na moeda antiga.

A opção número dois é você ter variáveis de estado e funções administrativas para permitir ajustes sem a necessidade de um novo deploy. Isso é fácil de fazer e perfeitamente possível em contratos simples. Mas simples mesmo, onde os bugs sejam de valores errados, inconsistências nos dados, etc. Nada que algumas funções sets definidas para o administrador usar não resolvam.

Mas para qualquer outra situação mais complexa o caminho é usar algum padrão de contratos atualizáveis (upgradeable contracts). Existem duas soluções muito famosas no mercado: os proxies e os adapters.

Os adapters são a solução mais simples, embora não sejam as melhores em diversos cenários. Eu explorei bastante um modelo de Adapter neste outro tutorial e resumidamente, você deve criar um contrato de adapter e um de implementação. No contrato de adapter você tem o endereço de onde está a implementação atual e funções administrativas para mudar isso. Além disso, o adapter expõe as funções que podem ser chamadas da implementação, delegando essas chamadas ao contrato de implementação correto conforme configurado previamente.

Tudo funciona bem do ponto de vista lógico, mas do ponto de vista de dados, eles ficam armazenados na implementação. Assim, caso você venha a mudar, eles serão perdidos e você terá de fazer algum plano de migração ou preparação. Assim, o pattern Adapter atende bem soluções de contratos que não tenham dados sensíveis e/ou que não possam ser migrados.

A solução perfeita para os demais cenários é usar o padrão proxy, que iremos explorar neste tutorial de hoje.

Curso Node.js e MongoDB

#2 – Proposta de Solução

A proposta que considero mais interessante para os cenários descritos é do pattern Proxy. À primeira vista um proxy e um adapter são idênticos e para o usuário eles são mesmo. A diferença é que um proxy possui um contrato Proxy que serve como conta, como storage e que delega todas as chamadas de funções a outro contrato, chamado de lógico. Assim, ao longo do tempo você pode atualizar seu contrato lógico sem perder fundos ou dados, apenas configurando o proxy para apontá-lo corretamente. Isso tudo sem o cliente nem sonhar que estão rolando atualizações.

Existem diversas implementações de proxies no mercado, sendo a imagem acima apenas uma simplificação comum para entendimento.

Por exemplo, existem os proxies transparentes que seguem a ERC-1967. Esta é uma abordagem mais pesada e complexa, mas que permite maior flexibilidade e poucas adaptações nos contratos. Já os UUPS (Universal Upgradeable Proxies Standard ou Padrão de Proxies Atualizáveis Universais) são documentados na ERC-1822 e são mais leves e simples, mas exigem mais customizações no contrato original que você deseja tornar upgradeable. Independente da abordagem escolhida, existem dois conceitos fundamentais da linguagem Solidity que são explorados e compartilhados por todos proxies:

  • Quando uma chamada de função é feita a um contrato e ele não possui a mesma, uma função de fallback é chamada no lugar. Você pode escrever um fallback personalizado para lidar com estes cenários, como por exemplo para redirecionar estas chamadas a contratos de implementação.
  • Toda vez que um contrato A delega uma chamada a outro contrato B, ele executa o código do contrato B, mas no contexto do contrato A. Isto significa que msg.value e msg.sender serão mantidos e que toda alteração no storage impactará o contrato A apenas.

Estes dois conceitos são a chave para você entender como proxies funcionam, independente do pattern escolhido. Para ajudar a ilustrar isso, considere o proxy abaixo. Ele não segue praticamente nenhum padrão, é apenas um exemplo simples e didático para entender minimamente como funciona. Mais à frente faremos um funcional.

A primeira coisa que você vê neste proxy são duas constantes, uma é a posição no storage do contrato onde armazenaremos o endereço do contrato de implementação e em outro é o endereço do owner/admin do contrato (falei sobre isso nesse artigo). Essas constantes são endereços fixos definidos na ERC-1967 e isso é necessário porque o contrato de proxy, além de rotear as requisições para a implementação serve como storage para os dados do contrato e a fim de evitar colisões, usa sempre as mesmas áreas de storage para estas informações essenciais que devem ser as únicas no seu estado.

Depois temos o constructor onde descobrimos o owner do contrato e armazenamos ele no endereço definido anteriormente. Repare que fazemos isso com código assembly, pois somente desta forma conseguimos a precisão no armazenamento dos dados que precisamos. Depois as duas funções seguintes, praticamente idênticas, usam de assembly para ler os mesmos endereços a fim de retornar os valores do admin e da implementação.

A função seguinte, upgrade, serve para atualizar o endereço da implementação, usado quando o contrato for atualizado e o proxy precisar redirecionar as mensagens para outro endereço. Novamente usamos assembly aqui para o armazenamento da informação, mas é na função _delegate que a maior complexidade do pattern é encontrada pois usando assembly nós redirecionamos todos os dados recebidos em uma requisição para outro contrato à nossa escolha na blockchain, com um comando chamado delegatecall. Essa função _delegate é chamada sempre que uma requisição ao proxy não encontre uma função de mesmo nome, caindo por padrão nas funções de fallback ou de receive, ao final do proxy.

Assim, com um proxy parecido com esse, é possível trocar o contrato de implementação ao longo do tempo de forma transparente para os usuários pois todos os dados e saldo ficam no proxy. No entanto, cuidados devem ser tomados com esta abordagem, como por exemplo com as alterações que você realizar: jamais remova variáveis de estado, não as renomeie ou mude a ordem das mesmas no contrato de implementação, pois isto pode fazer com que o storage do proxy seja sobrescrito. O ideal é apenas adicionar novas variáveis e quando precisar mudar valores das mesmas, o faça sempre dentro de funções, nunca no corpo do contrato.

#3 – Projeto com HardHat

Você deve ter reparado que a solução é de um nível bem baixo, usando bastante Assembly, o que torna um risco elevado de aplicar esse pattern. Pensando nisso, a solução que recomendo para implementar tal solução é usar os contratos da OpenZeppelin para isso. Eles fazem um trabalho incrível fornecendo contratos que podem ser usados como base, incluindo do pattern Proxy.

Sendo assim, para exercitarmos o pattern Proxy vamos criar um projeto de exemplo no HardHat e usar a OpenZeppelin e seus contratos atualizáveis. Porque HardHat e não no Remix? Porque o Remix não tem suporte a proxies transparentes.

Este não deve ser seu primeiro projeto com HardHat, pois não introduzirei o toolkit. Caso seja o seu caso, conheça-o neste tutorial. Crie um projeto normalmente e depois instale as dependências que vamos usar.

A saber:

  • DotEnv: para gerir configurações de ambiente;
  • @openzeppelin/contracts-upgradeable: para os contratos-base atualizáveis;
  • @openzeppelin/hardhat-upgrades: plugin para fazer deploy do proxy e atualizações da implementação de maneira facilitada;

Agora crie o .env para configurarmos as variáveis de ambiente que vamos usar.

No exemplo acima eu deixei configurado para a Testnet da Polygon (chamada Mumbai). Se esse RPC não funcionar, você pode providenciar um gratuito para você em provedores como Infura. Não esqueça de criar uma API Key no site polygonscan.com para podermos verificar os contratos.

Agora vamos ao hardhat.config.ts para configurá-lo com estas variáveis e o plugin de upgrades.

Agora vamos criar o nosso Contrato.sol, inicialmente configurado no site OpenZeppelin Contracts como Ownable e Upgradeability Transparent.

E depois ajustando para adicionar algumas funcionalidades próprias e mudança no nome.

Ponto importante: jamais inicialize variáveis no corpo do contrato, sempre o faça dentro de funções. Além disso, não coloque nada importante dentro do constructor, ao invés disso use a função initialize. Tudo isso por causa dos detalhes de baixo nível em Assembly que nós não iremos estudar a fundo.

Agora que temos o contrato codificado, é hora de escrever nossos testes unitários no Contrato.test.ts.

Aqui vale uma explicação com relação à função deployFixture. Embora ela não seja novidade aos desenvolvedores que usam HardHat, ela usa do plugin upgrades da OpenZeppelin, mais especificamente a função deployProxy, que se encarrega de fazer deploy do contrato de proxy e do contrato de implementação, inclusive linkando os dois ao final do processo, de forma transparente pra gente. Ao usarmos o objeto contract retornado por esta função, estaremos armazenando os dados no proxy e chamando as funções da implementação, o que fazemos no único cenário de teste que coloquei mais abaixo.

Eu exploro melhor essa funcionalidade no segundo teste, onde altero o estado do contrato, publico uma atualização dele e o estado se mantém, sem perder nenhum dado.

Para ir além nos testes, podemos fazer deploy na Mumbai Testnet. Atualmente o HardHat está mudando seu módulo de deploy para um novo, chamado HardHat Ignition, que não é compatível com OpenZeppelin Upgradeable Contracts (ainda), então ignore o método de deploy atual e vamos usar o antigo. Para isso, crie uma pasta scripts na raiz do seu projeto e depois um arquivo deploy.ts dentro dela, como abaixo.

Repare como ele é parecido com o script de deploy dos testes, então dispensa explicações adicionais. Ao fazer seu primeiro deploy, não esqueça de anotar o endereço do contrato de proxy, vamos precisar dele para atualizações futuras. Caso a Testnet não reconheça seu proxy automaticamente como um proxy (isso é necessário para conseguir testar pela interface do explorado de blocos), procure pela opção manual de verificação, como na imagem abaixo (canto inferior direito da imagem).

Caso ele reclame que o contrato de implementação não foi verificado, basta fazê-lo seja pelo terminal, seja manualmente, como com o comando abaixo.

Quando tudo estiver certo, você conseguirá testar seu proxy como se fosse o contrato de implementação, como abaixo.

Depois disso, é importante ter um script de update.ts também (na mesma pasta scripts), já que quando você quiser mexer na implementação do contrato e subir só ela, não vai querer fazer todo o deploy de novo.

Esse script usa a função uphgrades.upgradeProxy, que exige o endereço do proxy e o factory da implementação. Internamente ele verifica se houve mudança, faz o deploy da nova implementação e atualiza o proxy para apontar para ela. Obviamente você deve trocar o endereço que coloquei ali em cima para o seu, que você terá acesso após ter feito deploy pela primeira vez. Após fazer o update, terá de verificar o seu novo contrato de implementação para que a interface do proxy se atualize.

Para facilitar a execução dos scripts, recomendo ajustar seu package.json.

Faça os testes com os scripts e no próprio site explorador de blocos e verá que você pode mudar a implementação que não perderá seus dados armazenados, desde que use sempre a partir do endereço do proxy.

Espero que tenha ajudado!

Querendo aprender a reduzir o consumo de gás nos seus smart contracts? Se liga nesse artigo.

Olá, tudo bem?

O que você achou deste conteúdo? Conte nos comentários.

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *