Skip to content

Contratos Inteligentes ERC-20 Atualizáveis

Contratos Inteligentes ERC-20 Atualizáveis

https://miro.medium.com/v2/resize:fit:1100/format:webp/1*9puGlKmMCjZOJD-TrVxkaA.png

Em um dos meus artigos recentes, discuti como criar um contrato inteligente atualizável usando o padrão de atualização por proxy fornecido pela OpenZeppelin. Neste artigo, gostaria de aprofundar mais no assunto explorando contratos ERC20 atualizáveis.

Padrão de Atualização por Proxy

Mesmo que o código implantado na blockchain seja imutável, esse padrão nos permite modificá-lo fazendo uso de dois contratos: o proxy e o contrato de implementação.

https://miro.medium.com/v2/resize:fit:1100/format:webp/1*mKaTW4JO9C_8vgngsAbdmg.png

A ideia principal é que os usuários sempre interajam com o contrato proxy, que encaminhará as chamadas para o contrato de implementação. Para mudar o código, basta implantar uma nova versão do contrato de implementação e configurar o proxy para apontar para esta nova implementação.

Se você é novo neste tópico, sugiro que dê uma olhada neste artigo onde abordei esse assunto detalhadamente:

Tokens ERC-20

Vitalik Buterin e Fabian Vogelsteller estabeleceram um framework para o desenvolvimento de tokens fungíveis, uma categoria de ativos digitais ou tokens intercambiáveis com contrapartes idênticas em uma base um-para-um. Esse framework é reconhecido como o Padrão ERC-20.

Se você é novo neste tópico, sugiro que confira estes dois artigos onde abordei esse assunto detalhadamente:

Contratos Atualizáveis da OpenZeppelin

Ao criar contratos inteligentes atualizáveis com o padrão de atualização por proxy, não é possível usar construtores. Para resolver esta limitação, os construtores devem ser substituídos por funções regulares, comumente chamadas de inicializadores. Esses inicializadores, normalmente chamados de 'initialize', servem como repositório para a lógica do construtor.

No entanto, devemos garantir que a função de inicialização seja chamada apenas uma vez, como um construtor, e por essa razão a OpenZeppelin fornece um contrato base Initializable com um modificador initializer que cuida disso:

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract MyContract is Initializable {
   uint256 public x;

   function initialize(uint256 _x) public initializer {
       x = _x;
   }
}

Além disso, já que um construtor invoca automaticamente os construtores de todos os ancestrais do contrato, isso também deve ser implementado em nossa função inicializadora e a OpenZeppelin nos permite realizar isso utilizando o modificador onlyInitializing:

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract BaseContract is Initializable {
   uint256 public y;

   function initialize() public onlyInitializing {
       y = 42;
   }
}

contract MyContract is BaseContract {
   uint256 public x;

   function initialize(uint256 _x) public initializer {
       BaseContract.initialize(); // Não se esqueça dessa chamada!
       x = _x;
   }
}

Considerando os pontos anteriores, utilizar os contratos do padrão ERC-20 da OpenZeppelin para criar tokens atualizáveis não é viável. De fato, já que eles têm um construtor, ele deve ser substituído por um inicializador:

/

/ @openzeppelin/contracts/token/ERC20/ERC20.sol
pragma solidity ^0.8.0;

...

contract ERC20 is Context, IERC20 {

   ...

   string private _name;
   string private _symbol;

   constructor(string memory name_, string memory symbol_) {
       _name = name_;
       _symbol = symbol_;
   }

   ...
}

No entanto, a OpenZeppelin fornece uma solução através de um fork de contratos: openzeppelin/contracts-upgradeable. Nesta versão modificada, os construtores foram substituídos por inicializadores, permitindo a criação de tokens atualizáveis com maior flexibilidade.

Por exemplo, o contrato ERC20 foi substituído pelo ERC20Upgradeable:

// @openzeppelin/contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol
pragma solidity ^0.8.0;

...

contract ERC20Upgradeable is Initializable, ContextUpgradeable, IERC20Upgradeable {
   ...

   string private _name;
   string private _symbol;

   function __ERC20_init(string memory name_, string memory symbol_) internal onlyInitializing {
       __ERC20_init_unchained(name_, symbol_);
   }

   function __ERC20_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing {
       _name = name_;
       _symbol = symbol_;
   }

   ...
}

Demonstração Prática

Para criar um novo token ERC20 atualizável, fiz uso do OpenZeppelin Wizard, que simplifica a criação do contrato:

https://miro.medium.com/v2/resize:fit:1100/format:webp/1*63Fcx97aZe3IV0ovflJVjg.png

Escolhi chamar o token de UpgradeableToken e defini as seguintes características:

https://miro.medium.com/v2/resize:fit:428/format:webp/1*pg_j6xXjpCiW4AVjV9m_YA.png

Primeira versão do contrato

Este é o código gerado para mim pelo wizard:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract UpgradeableToken1 is Initializable, ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PausableUpgradeable, OwnableUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    // @custom:oz-upgrades-unsafe-allow construtor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) initializer public {
        __ERC20_init("UpgradeableToken", "UTK");
        __ERC20Burnable_init();
        __ERC20Pausable_init();
        __Ownable_init(initialOwner);

        _mint(msg.sender, 10000 * 10 ** decimals());
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    // As seguintes funções são revogações requeridas pelo Solidity.
    function _update(address from, address to, uint256 value)
        internal
        override(ERC20Upgradeable, ERC20PausableUpgradeable)
    {
        super._update(from, to, value);
    }
}

Este código representa a primeira versão do nosso contrato.

Criei um projeto Hardhat, contendo todo o código, neste repositório. Se você é novo no Hardhat, pode consultar este guia onde expliquei como criar um novo projeto do zero.

Para testar as principais funcionalidades do contrato anterior, criei o seguinte script:

import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { UpgradeableToken1, UpgradeableToken1__factory } from "../typechain-types";

describe("Contrato versão 1", () => {
  let UpgradeableToken1: UpgradeableToken1__factory;
  let token: UpgradeableToken1;
  let owner: HardhatEthersSigner;
  let addr1: HardhatEthersSigner;
  let addr2: HardhatEthersSigner;
  const DECIMALS: bigint = 10n ** 18n;
  const INITIAL_SUPPLY: bigint = 10_000n;

  beforeEach(async () => {
    UpgradeableToken1 = await ethers.getContractFactory("UpgradeableToken1");
    [owner, addr1, addr2] = await ethers.getSigners();
    token = await upgrades.deployProxy(UpgradeableToken1, [owner.address], { initializer: 'initialize', kind: 'transparent'});
    await token.waitForDeployment();
  });

  describe("Implantação", () => {
    it("Deve definir o nome correto", async () => {
      expect(await token.name()).to.equal("UpgradeableToken");
    });

    it("Deve definir o símbolo correto", async () => {
      expect(await token.symbol()).to.equal("UTK");
    });

    it("Deve definir o proprietário correto", async () => {
      expect(await token.owner()).to.equal(owner.address);
    });

    it("Deve atribuir o fornecimento inicial de tokens ao proprietário", async () => {
      const ownerBalance = await token.balanceOf(owner.address);
      expect(ownerBalance).to.equal(INITIAL_SUPPLY * DECIMALS);
      expect(await token.totalSupply()).to.equal(ownerBalance);
    });
  });

  describe("Transações", () => {
    it("Deve transferir tokens entre contas", async () => {
      // Transferir 50 tokens do proprietário para addr1
      await token.transfer(addr1.address, 50);
      const addr1Balance = await token.balanceOf(addr1.address);
      expect(addr1Balance).to.equal(50);

      // Transferir 50 tokens de addr1 para addr2
      // Usamos .connect(signer) para enviar uma transação de outra conta
      await token.connect(addr1).transfer(addr2.address, 50);
      const addr2Balance = await token.balanceOf(addr2.address);
      expect(addr2Balance).to.equal(50);
    });

    it("Deve falhar se o remetente não tiver tokens suficientes", async () => {
      const initialOwnerBalance = await token.balanceOf(owner.address);
      // Tentar enviar 1 token de addr1 (0 tokens) para o proprietário (1000000 tokens).
      expect(
        token.connect(addr1).transfer(owner.address, 1)
      ).to.be.revertedWithCustomError;

      // O saldo do proprietário não deve ter mudado.
      expect(await token.balanceOf(owner.address)).to.equal(
        initialOwnerBalance
      );
    });

    it("Deve atualizar saldos após transferências", async () => {
      const initialOwnerBalance: bigint = await token.balanceOf(owner.address);

      // Transferir 100 tokens do proprietário para addr1.
      await token.transfer(addr1.address, 100);

      // Transferir mais 50 tokens do proprietário para addr2.
      await token.transfer(addr2.address, 50);

      // Verificar saldos.
      const finalOwnerBalance = await token.balanceOf(owner.address);
      expect(finalOwnerBalance).to.equal(initialOwnerBalance - 150n);

      const addr1Balance = await token.balanceOf(addr1.address);
      expect(addr1Balance).to.equal(100);

      const addr2Balance = await token.balanceOf(addr2.address);
      expect(addr2Balance).to.equal(50);
    });
  });

  describe("Cunhagem", () => {
    it("Deve cunhar tokens para o endereço do proprietário", async () => {
      await token.mint(owner.address, 10n * DECIMALS);
      const ownerBalance: bigint = await token.balanceOf(owner.address);
      expect(ownerBalance).to.equal((INITIAL_SUPPLY +10n) * DECIMALS);
    });
  });

  describe("Queima", () => {
    it("Deve queimar tokens do endereço do proprietário", async () => {
      await token.burn(10n * DECIMALS);
      const ownerBalance: bigint = await token.balanceOf(owner.address);
      expect(ownerBalance).to.equal((INITIAL_SUPPLY -10n) * DECIMALS);
    });
  });

  describe("Recursos Pausáveis", () => {
    it("Deve pausar o contrato", async () => {
      await token.pause();
      expect(await token.paused()).to.be.true
      expect( token.transfer(addr1.address, 50)).to.be.revertedWithCustomError;
    });

    it("Deve retomar o contrato", async () => {
      await token.pause();
      await token.unpause();
      expect(await token.paused()).to.be.false
      expect(await token.transfer(addr1.address, 50)).not.throw;
    });
  });
});

Segunda versão do contrato

Agora, vamos imaginar que queremos adicionar um novo contrato contendo uma lista negra que irá congelar uma conta específica, impedindo-a de transferir, receber ou queimar tokens.

Para realizar essa tarefa, criei uma nova versão do UpgradeableToken, fazendo-o herdar um contrato BlackList que implementa um mecanismo de lista negra:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract BlackList is OwnableUpgradeable {
    mapping (address => bool) internal blackList;

    function isBlackListed(address maker) public view returns (bool) {
        return blackList[maker];
    }
    
    function addBlackList (address evilUser) public onlyOwner {
        blackList[evilUser] = true;
        emit AddedBlackList(evilUser);
    }

    function removeBlackList (address clearedUser) public onlyOwner {
        blackList[clearedUser] = false;
        emit RemovedBlackList(clearedUser);
    }

    event AddedBlackList(address user);
    event RemovedBlackList(address user);
}

contract UpgradeableToken2 is Initializable, ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PausableUpgradeable, OwnableUpgradeable, BlackList {
    
    /// @custom:oz-upgrades-unsafe-allow constructor
    // @custom:oz-upgrades-unsafe-allow construtor
    constructor() {
        _disableInitializers();
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    // As seguintes funções são revogações requeridas pelo Solidity.
    function _update(address from, address to, uint256 value)
        internal
        override(ERC20Upgradeable, ERC20PausableUpgradeable)
    {
        require(!isBlackListed(from), "O endereço do remetente está na lista negra");
        require(!isBlackListed(to), "O endereço do destinatário está na lista negra");
        super._update(from, to, value);
    }
}

Para impedir que um endereço na lista negra transfira, receba, queime ou obtenha novos tokens cunhados, foram colocados dois require na função _update. De fato, essa função é chamada sempre que chamamos as funções de transferência, queima e cunhagem.

Para testar a segunda versão do contrato com suas novas funcionalidades, foi usado o seguinte script:

import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { UpgradeableToken2, UpgradeableToken2__factory } from "../typechain-types";

describe("Contrato versão 2", () => {
  let UpgradeableToken2: UpgradeableToken2__factory;
  let newToken: UpgradeableToken2;
  let owner: HardhatEthersSigner;
  let addr1: HardhatEthersSigner;
  let addr2: HardhatEthersSigner;
  const DECIMALS: bigint = 10n ** 18n;
  const INITIAL_SUPPLY: bigint = 10_000n;

  beforeEach(async () => {
    const UpgradeableToken1 = await ethers.getContractFactory("UpgradeableToken1");
    UpgradeableToken2 = await ethers.getContractFactory('UpgradeableToken2');
    [owner, addr1, addr2] = await ethers.getSigners();
    const oldToken = await upgrades.deployProxy(UpgradeableToken1, [owner.address], { initializer: 'initialize', kind: 'transparent' });
    await oldToken.waitForDeployment();
    newToken = await upgrades.upgradeProxy(oldToken, UpgradeableToken2, { kind: 'transparent' });
  });

  describe("Implantação", () => {
    it("Deve definir o nome correto", async () => {
      expect(await newToken.name()).to.equal("UpgradeableToken");
    });

    it("Deve definir o símbolo correto", async () => {
      expect(await newToken.symbol()).to.equal("UTK");
    });

    it("Deve definir o proprietário correto", async () => {
      expect(await newToken.owner()).to.equal(owner.address);
    });

    it("Deve atribuir o fornecimento inicial de tokens ao proprietário", async () => {
      const ownerBalance = await newToken.balanceOf(owner.address);
      expect(ownerBalance).to.equal(INITIAL_SUPPLY * DECIMALS);
      expect(await newToken.totalSupply()).to.equal(ownerBalance);
    });
  });

  describe("Transações", () => {
    it("Deve transferir tokens entre contas", async () => {
      // Transferir 50 tokens do proprietário para addr1
      await newToken.transfer(addr1.address, 50);
      const addr1Balance = await newToken.balanceOf(addr1.address);
      expect(addr1Balance).to.equal(50);

      // Transferir 50 tokens de addr1 para addr2
      // Usamos .connect(signer) para enviar uma transação de outra conta
      await newToken.connect(addr1).transfer(addr2.address, 50);
      const addr2Balance = await newToken.balanceOf(addr2.address);
      expect(addr2Balance).to.equal(50);
    });

    it("Deve falhar se o remetente não tiver tokens suficientes", async () => {
      const initialOwnerBalance = await newToken.balanceOf(owner.address);
      // Tentar enviar 1 token de addr1 (0 tokens) para o proprietário (1000000 tokens).
      expect(
        newToken.connect(addr1).transfer(owner.address, 1)
      ).to.be.revertedWithCustomError;

      // O saldo do proprietário não deve ter mudado.
      expect(await newToken.balanceOf(owner.address)).to.equal(
        initialOwnerBalance
      );
    });

    it("Deve atualizar saldos após transferências", async () => {
      const initialOwnerBalance: bigint = await newToken.balanceOf(owner.address);

      // Transferir 100 tokens do proprietário para addr1.
      await newToken.transfer(addr1.address, 100);

      // Transferir mais 50 tokens do proprietário para addr2.
      await newToken.transfer(addr2.address, 50);

      // Verificar saldos.
      const finalOwnerBalance = await newToken.balanceOf(owner.address);
      expect(finalOwnerBalance).to.equal(initialOwnerBalance - 150n);

      const addr1Balance = await newToken.balanceOf(addr1.address);
      expect(addr1Balance).to.equal(100);

      const addr2Balance = await newToken.balanceOf(addr2.address);
      expect(addr2Balance).to.equal(50);
    });
  });

  describe("Cunhagem", () => {
    it("Deve cunhar tokens para o endereço do proprietário", async () => {
      await newToken.mint(owner.address, 10n * DECIMALS);
      const ownerBalance: bigint = await newToken.balanceOf(owner.address);
      expect(ownerBalance).to.equal((INITIAL_SUPPLY +10n) * DECIMALS);
    });
  });

  describe("Queima", () => {
    it("Deve queimar tokens do endereço do proprietário", async () => {
      await newToken.burn(10n * DECIMALS);
      const ownerBalance: bigint = await newToken.balanceOf(owner.address);
      expect(ownerBalance).to.equal((INITIAL_SUPPLY -10n) * DECIMALS);
    });
  });

  describe("Recursos Pausáveis", () => {
    it("Deve pausar o contrato", async () => {
      await newToken.pause();
      expect(await newToken.paused()).to.be.true
      expect(newToken.transfer(addr1.address, 50)).to.be.revertedWithCustomError;
    });

    it("Deve retomar o contrato", async () => {
      await newToken.pause();
      await newToken.unpause();
      expect(await newToken.paused()).to.be.false
      await newToken.transfer(addr1.address, 50);
      const addr1Balance = await newToken.balanceOf(addr1.address);
      expect(addr1Balance).to.equal(50);
    });
  });

  describe("Recursos da Lista Negra", () => {
    it("Deve adicionar o endereço à lista negra", async () => {
        expect(await newToken.isBlackListed(addr1)).to.be.false;
        await newToken.addBlackList(addr1);
        expect(await newToken.isBlackListed(addr1)).to.be.true;
    });

    it("Deve remover o endereço da lista negra", async () => {    
        await newToken.addBlackList(addr1);
        await(newToken.removeBlackList(addr1));
        expect(await newToken.isBlackListed(addr1)).to.be.false;
    });

    it("Deve impedir que o endereço na lista negra transfira fundos", async () => {
        await newToken.transfer(addr1.address, 10n * DECIMALS);    
        await newToken.addBlackList(addr1);
        expect(newToken.connect(addr1).transfer(addr2.address, 50))
        .to.be.revertedWith('O endereço do remetente está na lista negra');
    });

    it("Deve permitir que o endereço removido da lista negra transfira fundos", async () => {
        await newToken.transfer(addr1.address, 10n * DECIMALS);    
        await newToken.addBlackList(addr1);
        await newToken.removeBlackList(addr1);
        await newToken.connect(addr1).transfer(addr2.address, 50);
        const addr2Balance = await newToken.balanceOf(addr2.address);
        expect(addr2Balance).to.equal(50);
    });
  });
});

O script primeiramente implanta a primeira versão do contrato UpgradeableToken1 em:

const oldToken = await upgrades.deployProxy(UpgradeableToken1, [owner.address], { initializer: 'initialize', kind: 'transparent' });

E então atualiza o contrato implantando a segunda versão UpgradeableToken2:

newToken = await upgrades.upgradeProxy(oldToken, UpgradeableToken2, { kind: 'transparent' });

Vale observar que, em um ambiente de produção, provavelmente teria sido mais prudente usar um proxy UUPS em vez do transparente.

Conclusões

Neste artigo, exploramos a criação de tokens ERC20 atualizáveis usando a biblioteca da OpenZeppelin e o padrão de atualização por proxy. Ao fornecer trechos de código e scripts de teste para as versões inicial e atualizada, desmistificamos o processo.

O fork de contratos atualizáveis (contracts-upgradable) da OpenZeppelin surge como uma solução robusta para uma evolução contínua de contratos. Nossa abordagem prática para testes enfatiza a importância da confiabilidade no desenvolvimento de contratos inteligentes.

Espero que tenha sido útil e feliz programação!

Recursos

Repositório Github Contratos Inteligentes Atualizáveis na Ethereum Padrão de Atualização por Proxy da OpenZeppelin Fundamentos dos Tokens ERC-20 Desenvolvendo Tokens ERC-20 Escrevendo Contratos Atualizáveis com a OpenZeppelin Criar um novo projeto Hardhat Contratos inteligentes atualizáveis ERC-20 — dev.to Testando seu contrato inteligente atualizável — dev.to Entendendo contratos inteligentes atualizáveis na prática — dev.to

Artigo original publicado por Rosario Borgesi. Traduzido por Paulinho Giovannini.