Recentemente escrevi um tutorial aqui no blog ensinando os fundamentos sobre contratos multitoken e explicando o padrão ERC-1155, uma junção dos padrões ERC-20 e ERC-721 para unir o melhor dos dois mundos em um só contrato, muitas vezes sendo chamado inclusive de evolução dos contratos de NFTs. No tutorial de hoje, vamos avançar nossos estudos indo para a prática, ou seja, programando nosso Smart Contract seguindo o padrão. É importante você encarar este tutorial como uma parte 2 e que volte ao primeiro se não possuir domínio do assunto ainda.
Para este tutorial usarei a ferramenta Remix, mas você nem verá nada específico dela durante o tutorial, é apenas para abstrair qualquer aspecto de ambiente já que é a ferramenta mais crua que existe atualmente. Nada impede no entanto que você use dos mesmos códigos para escrever seu contrato em toolkits como Truffle e HardHat, o que eu mesmo pretendo fazer mais à frente aqui no blog e nos cursos.
Então abra o Remix, crie um novo arquivo que vamos chamar de MyTokens.sol e bora programar!
Primeiro a estrutura básica
Conforme explicitado na documentação oficial do padrão, todo contrato multi-token deve implementar as interfaces ERC1155 e ERC165. A primeira diz respeito às funções e eventos obrigatórios de todos contratos multi-token, enquanto que a segunda diz respeito a como o contrato avisa que é compliance com determinados padrões/interfaces.
Embora não seja obrigatório (até onde sei) você ter a interface declarada no seu código, pode ser um bom ponto de partida fazê-lo, para ter certeza que estará aderente aos padrões acima citados e ajuda em algumas funções como a exigida pelo ERC165 que veremos mais à frente. Então, logo no topo do seu MyTokens.sol, declare as interfaces tal como fornecidas pela Ethereum.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.2/contracts/utils/Strings.sol"; interface ERC165 { function supportsInterface(bytes4 interfaceID) external view returns (bool); } interface ERC1155 { event TransferSingle( address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value ); event TransferBatch( address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values ); event ApprovalForAll( address indexed _owner, address indexed _operator, bool _approved ); event URI(string _value, uint256 indexed _id); function safeTransferFrom( address _from, address _to, uint256 _id, uint256 _value, bytes calldata _data ) external; function safeBatchTransferFrom( address _from, address _to, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data ) external; function balanceOf(address _owner, uint256 _id) external view returns (uint256); function balanceOfBatch(address[] calldata _owners, uint256[] calldata _ids) external view returns (uint256[] memory); function setApprovalForAll(address _operator, bool _approved) external; function isApprovedForAll(address _owner, address _operator) external view returns (bool); } interface ERC1155TokenReceiver { function onERC1155Received( address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data ) external returns (bytes4); function onERC1155BatchReceived( address _operator, address _from, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data ) external returns (bytes4); } |
Com estas interfaces no seu código, o próximo passo é criar logo abaixo o contrato em si, que deverá implementar as mesmas.
1 2 3 4 5 |
contract MyTokens is ERC1155, ERC165 { } |
Repare que aqui eu implemento as interfaces previamente descritas com a keyword ‘is’. Agora é a hora que devemos estruturar as variáveis de estado do nosso contrato, necessárias para controle de propriedade dos tokens e outros.
1 2 3 4 5 6 7 8 |
mapping(uint256 => mapping(address => uint256)) private _balances; //tokenId => (owner => balance) mapping(address => mapping(address => bool)) private _approvals; //owner => (operator => approved) uint private constant NFT_1 = 0; uint private constant NFT_2 = 1; uint private constant NFT_3 = 2; |
Aqui temos um primeiro mapping que relaciona para cada token existente (a chave é o tokenId, um uint256) com cada um dos proprietários de uma cópia dele e seus saldos do respectivo token. Neste exemplo, estou considerando tokens semi-fungíveis, ou seja, NFTs com quantidade de unidades de cada exemplar.
A segunda variável é o mapeamento de contas para operadores aprovados. Ou seja, é o equivalente ao allowance dos tokens ERC-20, mas binário (tem ou não tem permissão). Diferente da ERC721, na 1155 ou você aprova acesso total à sua conta para um operador terceiro ou restringe tudo, não existe granularidade por tokenId no padrão, embora você possa implementar por conta.
Na sequência eu incluí três constantes para exemplificar a vantagem de um contrato multi-token. Esse contrato vai ter três tipos de NFT: a 1, a 2 e a 3, que por falta de criatividade apenas numerei, mas sei que você pode pensar em nomes melhores. Para cada tipo de token eu dei um id fixo, já que serão apenas 3, mas você pode fazer mecanismos no seu contrato para os ids serem infinitos ou considerar que cada NFT tem seu próprio id recebido no mint (semelhante ao que temos no ERC721), embora não é especificado minting no padrão.
Antes de entrarmos nas funções específicas da ERC-1155, vamos implementar a única função exigida pela ERC-165, a supportsInterface.
1 2 3 4 5 6 7 8 9 10 11 12 |
function supportsInterface(bytes4 interfaceID) external pure returns (bool) { return interfaceID == 0x01ffc9a7 || interfaceID == 0x4e2312e0 || interfaceID == 0x0e89341c; } |
Essa função retorna um booleano indicando se determinada interface passada por parâmetro está ou não implementada por este contrato. Como nosso contrato deverá implementar as interfaces ERC1155, ERC1155Metadata_URI e ERC165, é com elas que faço a comparação para devolver se o contrato suporta ou não a interface informada (os valores hexadecimais estão na especificação do padrão).
Implementando as funções de saldo e delegação
Por uma questão de organização vou quebrar as funções do contrato em quatro grupos: as funções de saldo, as funções de delegação, as de transferência e as funções opcionais, não obrigatórias. Vamos começar pelas funções de saldo e dentro desse grupo, com as funções single e batch.
1 2 3 4 5 6 7 8 9 10 |
function balanceOf(address _owner, uint256 _id) external view returns (uint256) { require(_id < 3, "This token does not exists"); return _balances[_id][_owner]; } |
A função balanceOf, exigida pela interface, espera o endereço do owner e o id de um token para consultar esse id no mapping de _balances para ver quantas unidades daquele token a conta possui. Caso o saldo retornado seja zero, indica que aquela conta não possui/não é dona de um token com aquele id. Também adicionei uma validação caso o id seja inválido.
Uma característica dos contratos 1155 é a exigência de funções batch, como abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function balanceOfBatch(address[] calldata _owners, uint256[] calldata _ids) external view returns (uint256[] memory) { require( _owners.length == _ids.length, "The array params must be equals in length" ); uint256[] memory result = new uint256[](_owners.length); for (uint256 i = 0; i < _owners.length; i++) { require(_ids[i] < 3, "This token does not exists"); result[i] = _balances[_ids[i]][_owners[i]]; } return result; } |
Aqui nós esperamos um array de owners e um array de tokenIds, que devem ser do mesmo tamanho. Para cada par de owner/tokenId nós consultamos o saldo, montamos um array com os resultados e devolvemos ao requisitante.
Além do saldo de tokens que também expressa a propriedade sobre os mesmos, a ERC-1155 determina que podemos delegar o controle de nossos tokens a outras carteiras, o que é especialmente útil para brokers, marketplaces, etc. Para isso, precisamos implementar algumas funções específicas que agem em cima de variáveis de estado que definimos antes.
1 2 3 4 5 6 7 8 9 |
function setApprovalForAll(address _operator, bool _approved) external { require(_operator != address(0), "The operator address cannot be zero"); _approvals[msg.sender][_operator] = _approved; emit ApprovalForAll(msg.sender, _operator, _approved); } |
Primeiro vamos falar da função setApprovalForAll, que é obrigatória do padrão e que começa já setando que, para o requisitante da transação (msg.sender), vamos definir que o operator tem controle total (approved = true) ou nenhum controle (approved = false) sobre todos os tokens do owner neste contrato. Ao término da execução dessa transação, emitimos um evento conforme manda a especificação. Note que é idêntico ao padrão ERC721.
1 2 3 4 5 6 7 8 9 |
function isApprovedForAll(address _owner, address _operator) external view returns (bool) { return _approvals[_owner][_operator]; } |
Já a segunda função, isApprovedForAll, verifica se determinado operador possui permissão sobre os tokens de um owner, o que inclusive pode ser substituído pela mudança de nome do mapping de aprovações e da sua visibilidade para public, se assim o quiser.
Já a terceira função (abaixo) desse grupo NÃO É do padrão ERC-1155
1 2 3 4 5 6 7 8 9 |
function _isApprovedOrOwner(address _owner, address _spender) private view returns (bool) { return _owner == _spender || _approvals[_owner][_spender]; } |
Ela retorna um booleano indicando se determinada carteira (spender) é o owner do token ou alguém aprovado pelo owner. Nada demais, mas muito útil em diversas situações que teremos a seguir.
Implementando as funções de transferência
Uma das principais vantagens de se usar tokens padronizados é que não apenas podemos registrar nossas criações e propriedades na blockchain como também podemos transferi-los para outras pessoas, o que é realizado através de duas funções de transferência exigidas pelo padrão ERC-1155, sendo a primeira delas a safeTransferFrom.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
function safeTransferFrom( address _from, address _to, uint256 _id, uint256 _value, bytes calldata _data ) external { require(_isApprovedOrOwner(_from, msg.sender), "Not authorized"); require(_from != address(0), "The from address must not be zero"); require(_to != address(0), "The to address must not be zero"); require(_from != _to, "The from and to addresses must be different"); require(_value > 0, "The value must not be zero"); require(_balances[_id][_from] >= _value, "Insufficient balance"); _balances[_id][_from] -= _value; _balances[_id][_to] += _value; emit TransferSingle(msg.sender, _from, _to, _id, _value); require( _to.code.length == 0 || ERC1155TokenReceiver(_to).onERC1155Received( msg.sender, _from, _id, _value, _data ) == ERC1155TokenReceiver.onERC1155Received.selector, "unsafe recipient" ); } |
A função safeTransferFrom espera que você informe quem é o dono atual do token, quem vai ser o novo dono, o id do token e a quantidade (value) a ser transferida. Ela não assume que o requisitante seja o dono automaticamente porque ele pode ser o controlador aprovado do mesmo, e não o dono original, mas ainda assim ele deve saber quem é o dono. Esse dono será validado na primeira linha da implementação, bem como se as contas envolvidas são carteiras válidas, se há saldo suficiente, etc. Tudo dando certo nas verificações, o balance dos envolvidos é atualizado.
Por fim, como manda o padrão, o evento de transferência é emitido e a verificação de destino compatível é realizada, afinal é uma safe transfer. Você já viu isso quando estudou o ERC721, então não vou me alongar aqui, apenas não se preocupe com o fato dela estar no final, já que em caso de falha toda transação será revertida.
E agora vamos falar da segunda função de transferência, que serve para transferir múltiplos tokens (diferentes) na mesma transação.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
function safeBatchTransferFrom( address _from, address _to, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data ) external { require(_isApprovedOrOwner(_from, msg.sender), "Not authorized"); require(_from != address(0), "The from address cannot be zero"); require(_to != address(0), "The to address cannot be zero"); require(_from != _to, "The from and to addresses cannot be equal"); require( _ids.length == _values.length, "The array params length must be equals" ); for (uint256 i = 0; i < _ids.length; i++) { require( _balances[_ids[i]][_from] >= _values[i], "Insufficient balance" ); _balances[_ids[i]][_from] -= _values[i]; _balances[_ids[i]][_to] += _values[i]; } emit TransferBatch(msg.sender, _from, _to, _ids, _values); require( _to.code.length == 0 || ERC1155TokenReceiver(_to).onERC1155BatchReceived( msg.sender, _from, _ids, _values, _data ) == ERC1155TokenReceiver.onERC1155BatchReceived.selector, "unsafe recipient" ); } |
Aqui temos um array de ids de tokens e um array de quantidades de tokens (values) e para cada par de id/value (motivo pelo qual eles devem ter o mesmo tamanho) nós devemos realizar uma safe transfer. Resista à tentação de fazer isso chamando a outra função, já que o custo em gás seria maior se você fizesse isso e essa é justamente a vantagem de uma batch transfer.
Além disso, repare que ao final das transferências um evento de transferência batch é emitido e a verificação de safe batch transfer é realizada. Como toda transação sempre é atômica, caso dê erro em QUALQUER etapa do processo para QUALQUER um dos token ids envolvidos, toda transação será revertida graças ao uso dos requires.
E com isso nós finalizamos toda a implementação obrigatória de funções e eventos definidos pela ERC-1155. Com o que implementamos já é possível implementar um contrato de multi-tokens e podemos definir no constructor do contrato que ao ser criado um token já será transferido para o criador do mesmo, ou seja, um único mint no deploy mesmo.
1 2 3 4 5 6 |
constructor(){ _balances[NFT_1][msg.sender] += 1; _currentSupply[NFT_1] -= 1; } |
Dessa forma, você já tem todas as funcionalidades multi-token, mas não da forma como normalmente são feitas os contratos profissionais, as grandes coleções, etc. Falaremos sobre algumas funções opcionais/adicionais que certamente você sentiu falta, na próxima parte 2 deste tutorial, que você confere neste link.
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.