Produto & UXARTIGO

Tutorial de Smart Contract ERC-721 (NFT) com Solidity (Parte 3)

Tutorial de Smart Contract ERC-721 (NFT) com Solidity (Parte 3)
Imagem: Luiz Fernando Duarte Junior

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. Caso queira, confira a Parte 1 e a Parte 2.

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 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 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 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 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 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 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 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 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).

Até a próxima!

 

*O conteúdo deste artigo é de responsabilidade do(a) autor(a) e não reflete necessariamente a opinião do iMasters.

Luiz Fernando Duarte JuniorAutor

Pós-graduado em computação, trabalha com software desde 2006 nas mais variadas tecnologias. Empreendedor, autor e professor, quando não está ocupado programando, está escrevendo ou gravando sobre programação para seu canal e blog LuizTools.

Ver perfil