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 50 51 |
// 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 4 5 |
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 5 6 |
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 6 7 |
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 5 6 |
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 5 6 |
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.
Além da propriedade, a ERC-721 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 |
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 12 13 |
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 5 6 |
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 10 11 |
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 15 16 |
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 4 5 |
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 11 12 |
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 16 17 |
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 5 6 |
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 que você confere neste link.
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.