WEB3DEV

Cover image for Contratos Inteligentes ERC-20 Atualizáveis
Paulo Gio
Paulo Gio

Posted on

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;
   }
}
Enter fullscreen mode Exit fullscreen mode

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;
   }
}
Enter fullscreen mode Exit fullscreen mode

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_;
   }

   ...
}
Enter fullscreen mode Exit fullscreen mode

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_;
   }

   ...
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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.

Latest comments (0)