Design an NFT smart contract for flexible coin creation and collection

We also ran out of gas. Understand why and how can we optimize it

The smart contract in question is the one we developed for Humans Of NFT. At the heart of this project is an art project — designed to explore collaboration and how the NFT series can be created by the community, not just for them.

Our smart contracts need to take into account various coinage requirements to cover various elements of our project, which we will explore in detail below.

Each of the 1,500 humans has a handwritten resume contributed by community members.

This is the first article in which we will explore the implementation of contracts – it is usually for those interested in our technical approach, but will be written in such a way that hopefully even the less skilled will learn something from it.

Before we dive into the technical issues, let’s provide some background on why contracts require so much functionality.

  • Our Genesis set (229 humans) was cast using Opensea’s ERC1155 shared contract. We wanted to merge old collectibles into new ones by burning the original tokens and capturing them in the contract, using our own ERC721 contract.
  • Authors in our author program receive free mints by submitting briefs (short for “biography,” or backstory) for our humans. We need to provide them with a way to claim their humans without paying a minting fee. Each author receives a different number of coins based on the number of profiles they submit.
  • We need to reserve 35 honorary humans (custom unique) with fixed token ids for individuals using wallet addresses.
  • We have a presale list and want to control who can enter and limit the number of tokens that can be minted.
  • Our public sale is open to everyone, but we want to limit the number of coins per transaction and per address.

Last, but certainly not least, we want to take advantage of a random coin minting strategy. Many projects use source hashes as seeds when randomizing metadata. We just revealed the metadata before the mint went live. This serves three main purposes.

  1. We wanted to reveal our entire collection before the event. We’ve been burned too many times by over-hyped mints that have produced very disappointing artwork after exposure, so we wanted to show our community exactly what they got.
  2. We wanted to make the process as fair and transparent as possible, removing the option for teams to mint select or rare tokens. We have the same odds as everyone else.
  3. We wanted to circumvent the need for disclosure to eliminate the possibility of sniping, and to ensure that if we didn’t mint the coins, the collection wouldn’t languish unrevealed.

Browse the collection before minting

An important disclaimer

Before I continue, I would like to say to this article that I am not a Solidity expert. I’ve been doing development for many years, working on a variety of very different projects, but this is the first smart contract I’ve deployed to the Ethereum mainnet, so this is NDA (non-developer advice).

I was lucky to have the help of some very smart people who guided me along the way. I just wanted to take this opportunity to share my thought process and why we make certain decisions, and hopefully it will help even someone starting their own NFT project, because I couldn’t have done it without learning from others who generously shared their experiences. There are a lot of great resources out there, and I’ll link to some of the resources I use at the bottom of this article.

Our verified contracts are available on Etherscan, if at any point you’d like to refer to the code while reading this guide.

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

Token ids are randomly assigned during coin creation

It is worth mentioning that this strategy adopts a pseudo-random number generation method. To do this “correctly”, you need to use a Chainlink like VRF (verifiable random function) (1).

We made an educated assumption that our relatively unknown, small collection (1500 pieces) and its low price (0.025 Eth) did little to inspire someone to come up with a sophisticated way to try and use it.

Furthermore, using methods like VRF would make our coinage process too expensive. After doing a lot of research and reading countless forum posts, I saw some extensions to 1001-Digital (2) ERC721 that included random token allocation.

The RandomlyAssigned extension doesn’t work for us because we need to “split” the collection between the known ID and the random ID (which we’ll explain shortly).

Also, you can see from the constructor that the “human” contract inherits this extension, as well as other contracts.

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; }
Copy the code

The RandomlyAssigned constructor now takes two arguments.

  • Total size of the set (MAX_HUMANS_SUPPLY )
  • Start randomly marking the index assigned by ID (NUMBER_OF_GENESIS_HUMANS + NUMBER_OF_RESERVED_HUMANS )

In our case, we had 229 Humans from the Genesis collection, so we wanted to preserve token ID1-229 for the burn-to-claim mechanism. In other words, the owner of Genesis ID #1 should receive ID #1 (that is, the replacement token) in the new collection.

We then reserved the token ID230-264 for specific addresses so that individuals who receive honor tokens can claim their Humans using pre-determined ids.

This leaves a pool of token ids between 265 and 1500, which should be randomly distributed, as we can see from the RandomlyAssigned constructor.

// RandomlyAssigned.sol
Copy the code

The maxSupply_ parameter is not used in the RandomlyAssigned contract, but is passed to the WithLimitedSupply contract it inherits.

The actual randomization utilizes a popular method of generating pseudo-random numbers (which I obviously can’t attribute to it) that casts a uint256 from a hash that uses block-specific data and the function caller’s address (msg.sender).

It then stores the results in a tokenMatrix map that stores which ids have been used.

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;
Copy the code

MaxIndex is the highest ID that can be assigned from the available pool. MaxAvailableSupply () returns the number of ids in the pool (i.e. 1500 — 229 — 35 = 1236), and tokenCount() returns the number of tokens that have been minted from the pool.

Therefore, if we have minted 150 tokens with random ids, then maxIndex = 1236 — 150, resulting in 1086, so our maxIndex is 1086. We convert the hash value generated using the Keccak256 algorithm to uint256 and then take the remainder from the modulo operation (%) when dividing by maxIndex (which always produces an integer less than maxIndex).

If you remember in the RandomlyAssigned constructor, we set the startFrom variable to equal the number of Tokens we keep (i.e. Genesis Tokens + Honoraries) +1.

So when we finally return the new random token ID, it will fall to 229 < random_id <= 1500.

Going back to the contract itself, the pool of available tokens is set up in the WithLimitedSupply constructor.

constructor(uint256 maxSupply_, uint256 reserved_) {  _maxAvailableSupply = maxSupply_ - reserved_; }
Copy the code

Each variant of the minting function then uses a modifier to check whether the required token number falls within the available range of the random ID pool to prevent someone from minting outside the desired range.

 /// @param amount Check whether number of tokens are still available /// @dev Check whether tokens are still available modifier ensureAvailabilityFor(uint256 amount) {  require(   availableTokenCount() >= amount,   'Requested number of tokens not available'  );  _; }
Copy the code

In the main contract, we have a handy function called _mintRandomId(), which is responsible for generating a random ID and casting the selected token to the address provided.

/// @dev internal check to ensure a genesis token ID, or ID outside of the collection, doesn't get mintedfunction _mintRandomId(address to) private { uint256 id = nextToken(); assert( id > NUMBER_OF_GENESIS_HUMANS + NUMBER_OF_RESERVED_HUMANS && id <= MAX_HUMANS_SUPPLY); _safeMint(to, id); }Copy the code

All in all, this approach is relatively straightforward — we admit it’s not a bulletproof solution, but we’re happy to say it worked as expected, and we’re proud of our unrevealing approach to starting the collection.

Some unexpected results

Since we did a lot of testing before we finally deployed to Mainnet, the contract worked exactly as we intended. We pride ourselves on the approach we have taken and how everything has (mostly) worked out.

We did encounter two minor problems during the coinage process, which unfortunately could have been avoided, but we took them as lessons learned. Neither of these problems was caused by a flaw in the contract, but by some flawed logic at the front end of our Mint website.

Despite all of our testing in the local environment and on the test network, we didn’t run into this particular issue until we launched a pre-sale campaign on the main network. We started getting reports from users that their transactions had failed because they had run out of fuel.

After doing some preliminary research (albeit flustered), we determined that Metamask did a very poor job of estimating gas limits for some (but not all) transactions.

We are still not 100% sure why this is the case, but my hypothesis at this stage is that it is at least partly due to randomization of token ids. Anyway, this is a relatively simple fix that requires a small patch to be deployed on the front end.

const GAS_LIMIT_PER: number = 200000;
Copy the code

The above snippet shows the simple fix we implemented, which involved manually setting gasLimit for each transaction based on the number of tokens being minted.

It should be noted that we significantly overestimated the allowance, which resulted in higher gas estimates, but the actual transaction used much less gas.

The amount of gas used when casting 1 token during presale

The other problem is a little more serious. The truth is, it was just an oversight, an amateur mistake on our part. Looking back, I think it was something we missed because we didn’t take into account the fact that the collection was sold out so quickly.

We really expected this collection to take days to sell out, let alone sell out in a minute, so it never occurred to us to check for this situation.

Obviously, in hindsight, this was a stupid mistake, because we really should have considered all the scenarios. Our error is that if availableTokenCount(), we did not prevent the user from calling mint, 0.

In addition, the user interface references incorrect variables, causing the supply shown to the user to reset once it reaches zero. As a result, many people continue to try minting, even though there are no more tokens available.

Because of the inclusion of the ensureAvailabilityFor modifier, the contract resumed trading as expected, but the user still incurred gas charges for the failed transaction. We fixed the front end in a matter of minutes and ended up refunding gasoline losses on more than 170 failed transactions.

Thankfully, none of the trades lost more than 0.004 Eth, so the losses were minimal. All in all, it was a valuable lesson, one that thankfully was not expensive.

In our next article, we’ll delve into how we handle off-chain presale/permit listings by using signature coupons.

[

Handle off-chain NFT presale/permitted list

Novel approach to using off-chain generated signature coupons instead of on-chain allowed lists.

medium.com

] (medium.com/@humansofnf…).

The appendix

(1) the docs. Chain. The link/docs/chainl…

(2) github.com/1001-digita…