WEB3DEV

Isabela Curado Nehme
Isabela Curado Nehme

Posted on

Projetando um Contrato Inteligente de NFT para cunhagem e reivindicações flexíveis

Artigo escrito por Humanos da NFT em 27 de janeiro - 9 minutos de leitura

Nós também ficamos sem gas. Saiba por que e como otimizamos.

Image description

Foto por Shubham Dhage na Unsplash

O contrato inteligente em questão é o que desenvolvemos para o Humans Of NFT.

O projeto é, em sua essência, um projeto artístico - com a intenção de ser uma exploração de colaborações e de como uma coleção NFT pode ser criada não somente para, mas por uma comunidade.

Nosso contrato inteligente precisava levar em conta uma variedade de diferentes necessidades de cunhagem para cobrir os vários elementos do nosso projeto, que vamos explorar detalhadamente abaixo.

Image description

Cada um dos 1500 Humanos tem uma biografia manuscrita contribuída por um membro da comunidade.

Este post é o primeiro em uma série que vai explorar as implementações do contrato - é destinado geralmente para pessoas que estão interessadas em nossa abordagem técnica, mas será escrito de tal forma que esperamos que até mesmo os menos tecnicamente inclinados possam aprender algo com isto.

Antes de mergulharmos nas coisas técnicas, vamos fornecer algum contexto sobre o motivo pelo qual o contrato precisava de tantos recursos:

  • Nossa coleção de gênesis (de 229 Humanos) foi cunhada usando o contrato compartilhado ERC1155 da Opensea. Queríamos mesclar a coleção antiga na nova, utilizando nosso próprio contrato ERC721, através da queima dos tokens originais e captura dos mesmos no contrato.

  • Autores em nosso Programa de Autores ganharam cunhagens gratuitas pelo envio de bios (abreviação de “biografia”, ou seja, uma história por trás) para nossos Humanos. Precisávamos dar a eles uma maneira de reivindicar seus Humanos sem ter que pagar para cunhar. Cada Autor ganhou um número diferente de cunhagens, dependendo de quantas bios eles enviaram.

  • 35 Humanos Honorários (individualmente customizados) com identidades de Token definidas precisavam ser reservados para indivíduos usando seus endereços de carteira.

  • Tínhamos uma lista de pré-vendas e queríamos controlar quem tinha acesso, assim como limitar o número de tokens que poderiam ser cunhados.

  • Nossa venda pública estava aberta a todos, mas nós queríamos limitar o número de cunhagens por transação e por endereço.

Por último, mas certamente não menos importante, queríamos utilizar uma estratégia de cunhagem aleatória. Muitos projetos utilizam um hash de proveniência como semente ao randomizar os metadados. Simplesmente revelamos os metadados antes que a cunhagem fosse lançada. Isso serviu para três propósitos principais:

  1. Queríamos revelar toda nossa coleção antes do evento de cunhagem. Já fomos enganados muitas vezes por cunhagens exageradas que renderam obras de arte realmente decepcionantes após a revelação, então queríamos mostrar para nossa comunidade exatamente o que eles estavam recebendo.

  2. Queríamos tornar o processo o mais justo e transparente possível, removendo a opção de a equipe cunhar tokens selecionados, ou raros. Tivemos as mesmas chances que todos os outros.

  3. Queríamos contornar a necessidade de um evento de revelação, para eliminar a possibilidade da ação de snipers no evento e garantir que a coleção não definharia na obscuridade desconhecida se não tivéssemos êxito na cunhagem.

Image description

Navegando na coleção antes do evento de cunhagem.
Na imagem: Humano #285 tem um problema em fazer amigos de verdade, tendo crescido em uma família rica. Não conte a ninguém, mas o macacão e a chave inglesa são apenas uma maneira deles parecerem da classe trabalhadora. O Humano #285 espera que o corte de cabelo Elvis distraia as pessoas de seus chinelos luxuosos porque eles se preocupam demais com as aparências e não conseguem manter contato visual.

Um aviso importante

Antes de continuar, eu quero advertir sobre este post dizendo que não sou nenhum especialista em Solidity. Sou desenvolvedor há muitos anos, trabalhando em uma ampla variedade de projetos bem diferentes, mas este foi o primeiro contrato inteligente que implantei na Ethereum Mainnet, então este é um NDA (Not Developer Advice - conselho de não desenvolvedor).

Tive a sorte de ter a atenção de algumas pessoas muito inteligentes que ajudaram a me guiar ao longo do caminho. Só quero usar isso como uma oportunidade de compartilhar meu processo de pensamento e as razões pelas quais tomamos certas decisões, na esperança de que possa ajudar até mesmo uma pessoa a embarcar em seu próprio projeto NFT, tendo em vista que eu não poderia tê-lo feito sem aprender com outros que tão generosamente compartilharam suas próprias experiências. Existem muitos recursos excelentes por aí, e eu vou linkar alguns dos que usei no final desse post.

Nosso contrato verificado pode ser encontrado na Etherscan, se em qualquer ponto você quiser consultar o código ao ler este guia:

https://etherscan.io/address/0x8575B2Dbbd7608A1629aDAA952abA74Bcc53d22A#code

Randomizando a distribuição dos IDs dos Token no momento da cunhagem

É válido mencionar que esta estratégia emprega um método de geração de números pseudorrandomizados (ou pseudoaleatórios). Fazer isso “corretamente” exigiria o uso de algo como a VRF (Verifiable Random Function - Função Aleatória Verificável) da Chainlink (1).

Fizemos uma suposição educada que nossa coleção relativamente desconhecida e pequena (de 1500) com seu baixo preço de cunhagem (0,025 Eth) deixou pouco ou nenhum incentivo para alguém inventar uma abordagem sofisticada para tentar explorá-la.

Além disso, utilizar uma abordagem como a VRF tornaria nosso processo de cunhagem proibitivamente caro. Depois de fazer uma extensa pesquisa e ler inúmeras postagens de fóruns, encontrei algumas extensões ERC721 da 1001-digital (2) que cobriam a atribuição aleatória de tokens.

A extensão RandomlyAssigned não funcionou imediatamente para nós, por causa da nossa necessidade de “dividir” a coleção entre IDs conhecidas e IDs aleatórias (que explicaremos em breve).

Enquanto isso, você pode ver no construtor que o contrato “The Humans” herda da extensão, junto com alguns outros:

constructor(
  string memory uri,
  address adminSigner,
  address openseaAddress
 )
  ERC721('Humans Of NFT', 'HUMAN')
  RandomlyAssigned(
   MAX_HUMANS_SUPPLY,
   NUMBER_OF_GENESIS_HUMANS + NUMBER_OF_RESERVED_HUMANS
  )
 {
  _defaultUri = uri;
  _adminSigner = adminSigner;
  _openseaSharedContractAddress = openseaAddress;
 }
Enter fullscreen mode Exit fullscreen mode

O construtor RandomlyAssigned agora recebe dois argumentos:

  • o tamanho total da coleção (MAX_HUMANS_SUPPLY)

  • o índice no qual iniciar a atribuição de ID de token aleatório (NUMBER_OF_GENESIS_HUMANS + NUMBER_OF_RESERVED_HUMANS).

No nosso caso, temos 229 Humanos da nossa coleção Genesis, portanto, queremos reservar IDs de token 1–229 para o mecanismo queimar-para-reivindicar. Em outras palavras, o proprietário do Genesis ID #1 deve receber o ID #1 na nova coleção (ou seja, o token de substituição).

Em seguida, temos IDs de token 230-264 reservadas para endereços específicos, para que os indivíduos que recebem tokens honorários possam reivindicar seus Humanos usando uma ID predeterminada.

Isso deixa uma pool de IDs de token entre 265-1500, os quais devem ser distribuídas aleatoriamente, o que podemos ver no construtor RandomlyAssigned:

// RandomlyAssigned.sol

constructor(uint256 maxSupply_, uint256 numReserved_)
  WithLimitedSupply(maxSupply_, numReserved_)
 {
  startFrom = numReserved_ + 1; 
 }
Enter fullscreen mode Exit fullscreen mode

O argumento maxSupply_ não é usado dentro do contrato RandomlyAssigned, mas, em vez disso, é transmitido para o contrato WithLimitedSupply do qual ele herda.

A randomização real faz uso de um método popular para gerar números pseudoaleatórios (que obviamente não posso levar crédito), que lança um uint256 de um hash gerado usando dados específicos do bloco, bem como o endereço dos chamadores da função (msg.sender).

Em seguida, ele armazena o resultado em um mapa tokenMatrix que armazena quais IDs já foram usadas:

function nextToken() internal override returns (uint256) {
  uint256 maxIndex = maxAvailableSupply() - tokenCount();
  uint256 random = uint256(
   keccak256(
    abi.encodePacked(
     msg.sender,
     block.coinbase,
     block.difficulty,
     block.gaslimit,
     block.timestamp
    )
   )
  ) % maxIndex;

  …

  return value + startFrom;

}
Enter fullscreen mode Exit fullscreen mode

A maxIndex é a ID mais alta possível que pode ser atribuída a partir da pool disponível. maxAvailableSupply() retorna o número de IDs na pool (ou seja, 1500 — 229 — 35 = 1236), e tokenCount() retorna o número de tokens que já foram cunhados da pool.

Então, se já cunhamos 150 tokens com IDs randomizadas, então maxIndex = 1236 — 150, que resulta em 1086, portanto nosso maxIndex é 1086. Estamos lançando o hash gerado usando o algoritmo keccak256 em um uint256, pegando o restante gerado pela operação módulo (%) ao dividi-lo pelo maxIndex (o que sempre resulta em um número inteiro menor que o maxIndex).

Se você se lembrar, no construtor RandomlyAssigned, definimos a variável startFrom para igualar o número de tokens que reservamos (ou seja,Tokens Genesis + Honorários) + 1.

Então, quando finalmente retornamos a nova ID de token aleatória, ela fica dentro do intervalo 229 < random_id <= 1500.

De volta ao próprio contrato, a pool de tokens disponível é definida no construtor WithLimitedSupply:

constructor(uint256 maxSupply_, uint256 reserved_) {
  _maxAvailableSupply = maxSupply_ - reserved_;
 }
Enter fullscreen mode Exit fullscreen mode

Cada variação da função de cunhagem utiliza um modificador que verifica que o número de tokens solicitados está dentro do limite do que resta disponível na pool de IDs aleatórias, para evitar que alguém cunhe uma ID fora do intervalo desejado.

 /// @param amount Verifica se o número de tokens ainda está disponível
 /// @dev Verifica se os tokens ainda estão disponíveis
 modifier EnsureAvailabilityFor(uint256 amount) {
  require(
   availableTokenCount() >= amount,
   'Requested number of tokens not available'
  );
  _;
 }
Enter fullscreen mode Exit fullscreen mode

Dentro do contrato principal, temos uma função de conveniência chamada _mintRandomId() que é responsável por gerar uma ID aleatória e cunhar o token selecionado para o endereço fornecido.

/// @dev Verificação interna para garantir que uma ID genesis de token, ou ID fora da coleção, não seja
criada 
function _mintRandomId(address to) private {
  uint256 id = nextToken();
  assert(
    id > NUMBER_OF_GENESIS_HUMANS + NUMBER_OF_RESERVED_HUMANS &&
    id <= MAX_HUMANS_SUPPLY
);
   _safeMint(to, id);
}
Enter fullscreen mode Exit fullscreen mode

Em suma, a abordagem é relativamente direta - reconhecemos que não é uma solução à prova de balas, mas podemos dizer com satisfação que funcionou como esperado e estamos orgulhosos de nossa abordagem sem revelação ao lançar a coleção.

Algumas consequências inesperadas

Graças ao fato de termos executado uma enxurrada de testes antes de finalmente implantar na mainnet, o contrato funcionou exatamente como pretendíamos. Estamos orgulhosos da abordagem que adotamos e de que (quase) tudo correu de forma suave.

Tivemos dois pequenos problemas durante a experiência de cunhagem que infelizmente poderiam ter sido evitados, mas estamos levando-os como lições aprendidas. Nenhum dos problemas resultou de uma falha no contrato, mas sim de alguma lógica falha no front-end do nosso site de cunhagem.

Apesar de todos nossos testes em um ambiente local e em redes de teste, não encontramos esse problema específico até que abrimos nosso evento de pré-venda na rede principal. Começamos a receber informes de alguns usuários de que suas transações estavam falhando porque estavam ficando sem gas.

Image description

Depois de fazer algumas investigações iniciais (embora com um pouco de pânico), determinamos que a Metamask estava fazendo um trabalho muito ruim ao estimar o limite de gas para algumas (mas não todas) transações.

Ainda não estamos 100% certos de por que esse foi o caso, mas minha suposição neste estágio é que isso se deve, pelo menos em parte, à randomização das IDs de token. De qualquer forma, foi uma correção relativamente simples que exigiu a implantação de um pequeno remendo no front-end.

const GAS_LIMIT_PER: number = 200000;

...

mintPresale(
  qty: number,
  priceInEth: number,
  coupon: String
)
{
  const mintPriceBn = utils.parseEther(priceInEth.toString());
  return this.contract.mintPresale(qty, coupon, {
    value: mintPriceBn.mul(qty),
    gasLimit: GAS_LIMIT_PER * qty,
  });
}
Enter fullscreen mode Exit fullscreen mode

O trecho acima mostra a correção simples que implementamos, que envolvia definir manualmente o gasLimit para cada transação com base no número de tokens sendo cunhados.

Deve-se notar que nós superestimamos muito o limite, o que resultou em estimativas maiores de gas, mas que a transação real consumiu consideravelmente menos gas.

Image description

O outro problema foi um pouco mais “sério”. A verdade é que foi simplesmente um descuido e um erro amador da nossa parte. Olhando para trás, acredito que foi algo que deixamos passar porque nós simplesmente não estávamos pensando na situação em que a coleção se esgotaria tão rapidamente.

Estávamos genuinamente esperando que a coleção levasse dias para ser cunhada, e não que isso acontecesse em menos de um minuto, então fazer verificações para tal situação simplesmente não passou pela nossa cabeça.
Obviamente, com o benefício da retrospectiva, este é um erro estúpido, pois deve-se levar em conta todos os cenários. Nosso erro foi que não impedimos que um usuário chamasse a função cunhar se availableTokenCount() retornasse 0.

Além disso, a interface do usuário estava referenciando a variável incorreta e fez com que o fornecimento mostrado ao usuário fosse redefinido quando atingisse zero. O resultado disso foi que muitas pessoas continuaram a tentar cunhar, mesmo quando não havia mais tokens disponíveis.

O contrato reverteu a transação, conforme esperado, devido à inclusão do modificador ensureAvailabilityFor, mas aos usuários ainda incorreram as taxas de gas pelas transações com falha. Implementamos uma correção no front-end em minutos e em última análise acabamos reembolsando as taxas de gas perdidas por mais de 170 transações com falha.

Felizmente, nenhuma transação perdeu mais de 0,004 Eth, então as perdas foram mínimas. Em suma, foi uma lição valiosa e, felizmente, que não foi muito cara.

Em nosso próximo post, vamos nos aprofundar em como lidamos com nossas listas de pré-venda/permissão off-chain por meio do uso de cupons assinados:

Lidando com pré-venda/listas de permissão NFT off-chain

Uma nova abordagem para usar cupons assinados gerados fora da cadeia em vez de uma lista de permissões na cadeia.

Apêndice

(1) https://docs.chain.link/docs/chainlink-vrf/

(2) https://github.com/1001-digital/erc721-extensions

Oldest comments (0)