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.