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.
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:
Escolhi chamar o token de UpgradeableToken
e defini as seguintes características:
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.
Latest comments (0)