Este tutorial é a parte 3 de uma série onde estou ensinando como construir smart contracts para coleções NFT usando o padrão ERC-721 com a linguagem Solidity. Se você ainda não fez as partes 1 e 2, comece por esse link.
Tivemos introduções teóricas, construção da estrutura principal e obrigatória da especificação 721 e na parte 2 chegamos inclusive a construir uma função de mint seguindo sugestões da especificação como o uso de URIs JSON MetaData. Nesta terceira parte, vamos trazer mais algumas funções comuns, mas opcionais, que valem a pena ser estudadas e que também são sugeridas na especificação.
Burning
Outra função extremamente relevante para alguns projetos é a de burning. Burning é o ato de destruir (queimar, no inglês) um token por qualquer que seja o motivo. Na nossa estrutura de contrato pode implementar uma função de burn da seguinte maneira.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function burn(uint tokenId) public { address lastOwner = _ownerOf[tokenId]; require(lastOwner != address(0), "Not minted"); require( _isApprovedOrOwner(lastOwner, msg.sender, tokenId), "Not permitted" ); _balanceOf[lastOwner]--; _ownerOf[tokenId] = address(0); delete _uris[tokenId]; delete _approvals[tokenId]; emit Transfer(lastOwner, address(0), tokenId); emit Approval(lastOwner, address(0), tokenId); } |
Comecei pegando o endereço do owner atual e verificando duas coisas com ele:
- primeiro, ele não pode ser zero, caso contrário quer dizer que o token não foi mintado ainda;
- segundo, ele deve ser o requisitante do burn ou ter dado permissão ao requisitando (approve ou approveForAll);
Após essas duas verificações, eu reduzo o balance do dono do token, destruo o registro de sua propriedade setando o endereço para zero e por último limpo a uri dele para salvarmos espaço no bloco. Também aproveito para limpar a delegação/aprovação de controle, se houver, e emitir os eventos que sou obrigado pelo padrão ERC721, avisando que houve uma transferência e uma mudança de autorização, ambas ligadas à carteira de endereço zero.
Enumerable Extension
Outras funções extremamente úteis e inclusive sugeridas na especificação são as relacionadas à extensão Enumerable. Mas o que seria um contrato NFT com enumeração? É basicamente um contrato que possui funções que permitem acessar os NFTs dos owners pelo seu índice ao invés de tokenId, facilitando a construção de dapps, marketplaces, etc. Sem esse tipo de funcionalidade você tem de saber o id de cada token para poder acessá-lo, o que muitas vezes é inviável no caso deles serem hashes.
Abaixo segue a interface ERC721Enumerable tal como sugerida na especificação:
1 2 3 4 5 6 7 8 9 |
interface ERC721Enumerable { function totalSupply() external view returns (uint256); function tokenByIndex(uint256 _index) external view returns (uint256); function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256); } |
Ela prevê as seguintes funções:
- totalSupply: quantidade total de NFTs existentes no contrato (owner != 0);
- tokenByIndex: acessa um token pelo seu índice, dentre todos da coleção;
- tokenOfOwnerByIndex: acessa um token de um owner pelo seu índice;
Para implementarmos esta extensão, copie a interface acima e coloque no nosso MyNFT.sol, junto das demais interfaces. Depois, modifique a função supportsInterface para inclui-la.
1 2 3 4 5 6 7 8 9 10 11 |
function supportsInterface( bytes4 interfaceId ) external pure returns (bool) { return interfaceId == 0x80ac58cd || //ERC721 interfaceId == 0x01ffc9a7 || //ERC165 interfaceId == 0x5b5e139f || //ERC721Metadata interfaceId == 0x780e9d63; //ERC721Enumerable } |
E agora vamos implementar as três novas funções, começando por algumas variáveis de estado novas que serão necessárias.
1 2 3 4 5 6 7 8 9 |
mapping(address => mapping(uint256 => uint256)) private _ownedTokens;//owner => (owner index => tokenId) mapping(uint256 => uint256) private _ownedTokensIndex;//tokenId => owner index uint256[] private _allTokens; mapping(uint256 => uint256) private _allTokensIndex;//tokenId => global idnex |
Estas variáveis controlam o estado dos índices, sendo elas.
- _ownedTokens: mapping que relaciona para cada owner todos os índices e tokens dele;
- _ownedTokensIndex: mapping que relaciona para cada token, o seu índice de owner;
- _allTokens: array contendo todos os tokens existentes;
- _allTokensIndex: mapping que relaciona para cada token, o seu índice global;
Agora podemos usar estas variáveis na implementação das funções da interface.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function totalSupply() external view returns (uint256) { return _allTokens.length; } function tokenByIndex(uint256 _index) external view returns (uint256){ require(_index < _allTokens.length, "Global index out of bounds"); return _allTokens[_index]; } function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256){ require(_index < _balanceOf[_owner], "Owner index out of bounds"); return _ownedTokens[_owner][_index]; } |
As três funções são bem simples mas atendem ao seu propósito de fornecer mecanismos de enumeração dos tokens.
A primeira, totalSupply, apenas conta quantos tokens nos temos no array _allTokens.
A segunda, tokenByIndex, retorna o tokenId dado uma posição no array global de tokens, mas não sem antes verificar se aquele índice existe.
E a terceira e última, tokenOwnerByIndex, faz o mesmo que a anterior, mas apenas dentro do escopo de tokens de um owner específico, que também tem o índice validado antes de continuar.
Repare que estas funções dependem que as variáveis de controle estejam devidamente atualizadas, o que por sua vez exige que a gente atualize as funções existentes de mint e de burn em nosso contrato.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function mint() public { _lastId += 1; _balanceOf[msg.sender]++; _ownerOf[_lastId] = msg.sender; _uris[_lastId] = string.concat( "https://www.luiztools.com.br/nfts/", Strings.toString(_lastId), ".json" ); _allTokens.push(_lastId); _allTokensIndex[_lastId] = _allTokens.length - 1; _ownedTokens[msg.sender][_balanceOf[msg.sender] - 1] = _lastId; _ownedTokensIndex[_lastId] = _balanceOf[msg.sender] - 1; emit Transfer(address(0), msg.sender, _lastId); } |
Aqui na função de mint eu adicionei quatro novas linhas pouco antes de emitir o evento de transferência, visando garantir que cada novo token mintado receba um índice ao final, tanto do array global quanto do array virtual de cada owner. Primeiro adicionamos o novo token id no array global. Depois, adicionamos a posição do novo token no mapping de índices globais, usando a quantidade de tokens para saber a última posição existente. Estas duas primeiras instruções atualizam as variáveis de estado ligadas à enumeração global de tokens.
Na sequência, adicionamos o novo id no mapping que relaciona ids com índices, usando o saldo de tokens do owner para sempre atribuir a última posição como índice. E por último, adicionamos o novo id no mapping que relaciona as posições dos tokens de cada owner. Estas duas últimas instruções atualizam as variáveis de estado ligadas à enumeração de tokens de cada owner.
Mas e o burn?
Burn com Enumerable
Aqui o desafio é um pouco maior, mas não muito. Isso porque ao destruirmos um NFT isso pode deixar “buracos” na organização dos índices quando o NFT destruído não estava no final da lista global ou até mesmo da lista virtual de algum dos owners. Neste caso nós temos três abordagens do que podemos fazer:
- nada: isto é, deixar os buracos. Assim, pode ser que ao acessar um índice de NFT, global ou de owner, venha o id de um NFT já destruído, o que não gera custo adicional de gás mas acaba consumindo espaço desnecessário e prejudica a experiência do usuário;
- reordenar: isto é, reorganizar todos os itens do array global e do virtual por owner para que o buraco seja preenchido sem perder a ordem original dos tokens (ordem de emissão). Esta abordagem possui custo de gás linear (O(n)) e a melhor experiência para o usuário, além de não desperdiçar espaço em disco;
- preencher o buraco: isto é, reorganizar apenas um elemento no array a fim de preencher o buraco deixado pelo burn de outro. Esta abordagem é um meio termo pois possui um custo de gás estável (O(1)), não desperdiça espaço em disco, mas pode causar alguma estranheza no usuário pois a ordem dos NFTs não irá respeitar a ordem de emissão após ele fazer um ou mais burns.
Dadas estas características, eu vou fazer aqui a terceira opção, mas sinta-se livre para modificar a sua implementação conforme suas preferências e objetivos com este tipo de contrato.
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 |
function burn(uint tokenId) public { address lastOwner = _ownerOf[tokenId]; require(lastOwner != address(0), "Not minted"); require( _isApprovedOrOwner(lastOwner, msg.sender, tokenId), "Not permitted" ); _balanceOf[lastOwner]--; _ownerOf[tokenId] = address(0); delete _uris[tokenId]; delete _approvals[tokenId]; //descobre qual index global deve ser removido uint removedIndex = _allTokensIndex[tokenId]; //copia o último elemento pra posição excluída _allTokens[removedIndex] = _allTokens[_allTokens.length - 1]; //remove a cópia do final do array, simulando movimentação _allTokens.pop(); //remove do índice de tokens delete _allTokensIndex[tokenId]; //descobre qual owner index deve ser removido uint removedOwnerIndex = _ownedTokensIndex[tokenId]; //sobrescreve o mapping de tokens do owner com cópia do último (balance porque já foi decrementado) _ownedTokens[msg.sender][removedOwnerIndex] = _ownedTokens[msg.sender][_balanceOf[msg.sender]]; //exclui o último que está duplicado (balance porque já foi decrementado) delete _ownedTokens[msg.sender][_balanceOf[msg.sender]]; //exclui do índice de tokens por owner delete _ownedTokensIndex[tokenId]; emit Transfer(lastOwner, address(0), tokenId); emit Approval(lastOwner, address(0), tokenId); } |
Eu incluí alguns comentários pois é um código de legibilidade questionável devido à relativa complexidade da lógica. Ainda assim, um rápido resumo seria que a estratégia consiste em fazer uma cópia do último elemento do array global para a posição a ser excluída, sobreescrevendo-a e em seguida removendo o elemento duplicado que estava no final. Isso é feito tanto para o array global quanto para o array virtual do owner que teve o token queimado.
O resultado prático desta lógica é que o último token é movido para a posição que o burn tornou vaga, a fim de preenchê-la.
Com estas novas funções seu contrato de NFT certamente ficou mais completo e abaixo seguem algumas sugestões de outras funções que pode ser interessante você criar:
- withdraw: função para fazer os saques, no caso de NFTs payable;
- pause: função para pausar novos mints, a fim de permitir alguma manutenção com segurança;
Outra dica é você hospedar tanto os metadados quanto a mídia dos seus NFTs no IPFS, falo disso no vídeo abaixo e neste tutorial.
E com isso finalizamos mais esta etapa no desenvolvimento de nosso contrato de NFT. Conseguimos cobrir o principal nessas três partes e espero ter te trazido um melhor entendimento sobre este tipo de implementação que é um dos mais requisitados para os profissionais web3 atualmente.
Quer aprender uma forma ainda mais profissional de implementar? Confira neste tutorial com HardHat e OpenZeppelin.
Quer aprender uma forma com menos taxas de gás no minting? Confira neste tutorial do ERC-721a (Azuki). E se quiser aprender a reduzir o consumo de gás nos seus smart contracts, este é o artigo certo.
Outro padrão muito popular para NFTs é a ERC-1155 que ensino neste tutorial.
Até a próxima!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.