Recentemente escrevi um tutorial aqui no blog ensinando os fundamentos sobre NFTs e explicando o padrão ERC-721, o primeiro e mais importante quando o assunto são smart contracts 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 MyNFT.sol e bora programar!
Primeiro a estrutura básica
Conforme explicitado na documentação oficial do padrão, todo contrato NFT deve implementar as interfaces ERC721 e ERC165. A primeira diz respeito às funções e eventos obrigatórios de todas NFTs, 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 MyNFT.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
|
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
interface ERC165 {
function supportsInterface(bytes4 interfaceID) external view returns (bool);
}
interface ERC721 {
event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
function balanceOf(address owner) external view returns (uint balance);
function ownerOf(uint tokenId) external view returns (address owner);
function safeTransferFrom(address from, address to, uint tokenId) external payable;
function safeTransferFrom(
address from,
address to,
uint tokenId,
bytes calldata data
) external payable;
function transferFrom(address from, address to, uint tokenId) external payable;
function approve(address to, uint tokenId) external;
function getApproved(uint tokenId) external view returns (address operator);
function setApprovalForAll(address operator, bool _approved) external;
function isApprovedForAll(
address owner,
address operator
) external view returns (bool);
}
interface ERC721Receiver {
function onERC721Received(
address operator,
address from,
uint tokenId,
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
|
contract MyNFT is ERC721, 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 NFTs e outros.
1
2
3
4
|
mapping(uint => address) internal _ownerOf;
mapping(address => uint) internal _balanceOf;
mapping(uint => address) internal _approvals;
mapping(address => mapping(address => bool)) public isApprovedForAll;
|
Aqui temos um primeiro mapping que relaciona para cada NFT existente (a chave é o tokenId, um uint256) com o seu dono (endereço da carteira).
Depois temos um segundo mapping que relaciona para cada carteira de dono, quantos NFTs ele possui.
A terceira variável é o mapeamento de tokenIds para operadores aprovados. Ou seja, é o equivalente ao allowance dos tokens ERC-20, mas binário (tem ou não tem permissão).
E por último temos um mapeamento de, dado um owner (primeiro address), tem-se o mapeamento de operadores com permissão total na coleção NFT daquele owner. Note que as primeiras três variáveis possuem _ no nome por serem internas (de uso interno do contrato), enquanto que a isApprovedForAll é pública e possui exatamente o mesmo nome definido no padrão, ou seja, uma função a menos para escrevermos!
E antes de entrarmos nas funções específicas da ERC-721, vamos implementar a única função exigida pela ERC-165, a supportsInterface.
1
2
3
4
5
|
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return
interfaceId == 0x80ac58cd || //ERC721
interfaceId == t0x01ffc9a7; //ERC165
}
|
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 ERC721 e ERC165, é com elas que faço a comparação para devolver se o contrato suporta ou não a interface informada.
Implementando as funções de propriedade e delegação
Por uma questão de organização vou quebrar as funções do contrato em quatro grupos: as funções de propriedade, 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 propriedade e dentro desse grupo, com as duas funções de leitura/verificação de propriedade.
1
2
3
4
|
function ownerOf(uint id) external view returns (address owner) {
owner = _ownerOf[id];
require(owner != address(0), “token doesn’t exist”);
}
|
A função ownerOf, exigida pela interface, espera o id e um token e consulta esse id no mapping de owners para ver quem é o dono do mesmo. Caso o endereço retornado seja 0, optei por devolver um erro informando que o token em questão não existe. Além disso, repare que não usei a instrução return ao término da função. Ao invés disso, nomeei a variável da instrução returns na assinatura da função, assim ela fica “ligada” à variável de mesmo nome existente no corpo da mesma.
1
2
3
4
|
function balanceOf(address owner) external view returns (uint) {
require(owner != address(0), “owner = zero address”);
return _balanceOf[owner];
}
|
Já a função balanceOf, também exigida pela interface, recebe o endereço de uma carteira que usamos para procurar no mapping de balances para retornar quantos tokens aquele usuário possui, devolvendo um erro caso a carteira informada seja zero.
1
2
3
4
|
function setApprovalForAll(address operator, bool approved) external {
isApprovedForAll[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, chamado de owner), vamos definir que o operator tem controle total (approved = true) ou nenhum controle (approved = false) sobre toda coleção NFT do owner. Ao término da execução dessa transação, emitimos um evento conforme manda a especificação.
1
2
3
4
5
6
7
8
9
10
11
|
function approve(address spender, uint id) external {
address owner = _ownerOf[id];
require(
msg.sender == owner || isApprovedForAll[owner][msg.sender],
“not authorized”
);
_approvals[id] = spender;
emit Approval(owner, spender, id);
}
|
Já a segunda função, approve, faz a mesma coisa mas para apenas uma NFT do owner. Ela começa pegando a informação de quem é o owner do token que vamos delegar controle e se esse owner não for o requisitante ou não estiver como controlador total da coleção, dará erro de não autorizado. Caso ele possua permissão, então o operador informado (spender) é adicionado como tendo aprovação sobre o token de id também informado. Ao término da função, o evento Approval é disparado como exigido pelo padrão.
1
2
3
4
|
function getApproved(uint id) external view returns (address) {
require(_ownerOf[id] != address(0), “token doesn’t exist”);
return _approvals[id];
}
|
A terceira função serve para retornar quem é o operador/controlador aprovado para um determinado token. Caso o token não exista, isso é informado como um erro também. Caso não exista aprovação para o token em questão, o endereço zero será retornado, como é de praxe no Solidity.
1
2
3
4
5
6
7
8
9
|
function _isApprovedOrOwner(
address owner,
address spender,
uint id
) internal view returns (bool) {
return (spender == owner ||
isApprovedForAll[owner][spender] ||
spender == _approvals[id]);
}
|
Já a quarta e última função desse grupo NÃO É do padrão ERC-721, é uma função própria, muito útil para algumas verificações mais adiante. Ela retorna um booleano indicando se determinada carteira (spender) é o owner do token ou alguém aprovado pelo owner. Nada demais.
Implementando as funções de transferência
Uma das principais vantagens de se usar NFTs padronizadas é que não apenas podemos registrar nossas criações e propriedades na blockchain como também podemos transferi-las para outras pessoas, o que é realizado através de três funções de transferência exigidas pelo padrão ERC-721, sendo a primeira delas a transferFrom, que vou implementar ligeiramente diferente do padrão, mas já explico o porque.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function _transferFrom(address from, address to, uint id) internal {
require(from == _ownerOf[id], “from != owner”);
require(to != address(0), “transfer to zero address”);
require(_isApprovedOrOwner(from, msg.sender, id), “not authorized”);
_balanceOf[from]—;
_balanceOf[to]++;
_ownerOf[id] = to;
delete _approvals[id];
emit Transfer(from, to, id);
}
|
A função transferFrom espera que você informa quem é o dono atual do token, quem vai ser o novo dono e o id do token. 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 o novo dono é uma carteira válida.
Depois, verificamos através de outro require se o msg.sender é o dono ou possui permissão para fazer esta transferência. Ele tendo, o balance do owner atual é diminuído, o balance do novo dono é incrementado, e a posse do tokenId é alterada no mapping de owners.
Por fim, como manda o padrão, todas as aprovações existentes para este token são revogadas e um evento de transferência é emitido.
Eu implementei esta função ligeiramente diferente do padrão pois preciso de uma função para uso interno no contrato, de modo a reaproveitar a lógica nas próximas funções. Mesmo assim, é importante que tenhamos outra transferFrom, essa sim como mandar o contrato para estarmos 100% aderentes, como segue.
1
2
3
|
function transferFrom(address from, address to, uint id) external payable {
_transferFrom(from, to, id);
}
|
Aí sim nós temos a função externa com o nome especificado no ERC721 e a interna para uso do próprio contrato e quem faz o trabalho duro de fato. Agora vamos falar das duas funções safeTransferFrom que são versões mais seguras de transferir NFTs.
1
2
3
4
5
6
7
8
9
10
|
function safeTransferFrom(address from, address to, uint id) external payable {
_transferFrom(from, to, id);
require(
to.code.length == 0 ||
ERC721Receiver(to).onERC721Received(msg.sender, from, id, “”) ==
ERC721Receiver.onERC721Received.selector,
“unsafe recipient”
);
}
|
Repare que a safeTransferFrom possui exatamente a mesma assinatura da transferFrom original e que ela inclusive chama a outra internamente. Mas repare também que ela é considerada “safe” por causa de uma validação adicional ao final da mesma que evita que seja transferido para um recipiente inválido. Mas o que seria um recipiente inválido?
A primeira coisa a se testar é se o to.code, ou seja, os bytes de código-fonte ligados àquele endereço são zero. Se forem zero, quer dizer que o endereço ‘to’ é uma conta comum e não um contrato, o que é totalmente válido. Caso seja um smart contract (code.length > 0), então deve ser feito outro teste, onde verificamos se a resposta à chamada da função onERC721Received indica que o token foi recebido com sucesso. Lembrando que mesmo este require estando após a transferência, que se ele der negativo, a transação como um todo será desfeita.
E agora vamos falar da segunda função de transferência segura, que muda apenas um elemento na assinatura.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
function safeTransferFrom(
address from,
address to,
uint id,
bytes calldata data
) external payable {
_transferFrom(from, to, id);
require(
to.code.length == 0 ||
ERC721Receiver(to).onERC721Received(msg.sender, from, id, data) ==
ERC721Receiver.onERC721Received.selector,
“unsafe recipient”
);
}
|
Aqui temos um parâmetro a mais chamado data e com isso uma sobrecarga de função (overload), permitindo chamar safeTransferFrom com ou sem esse último parâmetro que serve para passar dados adicionais, à gosto do desenvolvedor. Além disso vale ressaltar que o local de armazenamento dele foi definido como calldata que é mais econômico que memory (em gás) mas não permite alteração desse parâmetro internamente na função (é immutable).
E com isso nós finalizamos toda a implementação obrigatória de funções e eventos definidos pela ERC-721. Com o que implementamos já é possível implementar um contrato de NFT e podemos definir no constructor do contrato que ao ser criado a nossa NFT já será transferida para o criador do mesmo, ou seja, um único mint no deploy mesmo.
1
2
3
4
|
constructor(){
_ownerOf[0] = msg.sender;
_balanceOf[msg.sender] += 1;
}
|
Dessa forma, você já tem todas as funcionalidades de NFT, mas não da forma como normalmente são feitas as NFTs comerciais, as grandes coleções, etc. Falaremos sobre algumas funções opcionais/adicionais que certamente você sentiu falta, na próxima parte deste tutorial.