How To Mint NFTs At A Fixed Gas Cost

Nathan Gang
Nifty Gateway
6 min readMar 28, 2022

--

Amid the rise in popularity of NFT projects, there is no shortage of articles sharing strategies for reducing minting gas costs. Strategies such as lazy minting, the use of L2 side chains, bulk minting, and other creative solutions to reduce gas fees for creating NFTs come to mind.

In the first article in this series, we introduced the upcoming Nifty Gateway smart contract upgrade that features fixed-cost minting. In this article, we will do a deep-dive on how you can write your own contract to mint your project at a fixed cost, no matter how big your collection is.

When optimizing a contract for gas efficiency, it is critical to understand what kind of operations are the most expensive. One of the most costly opcodes on the EVM by far is SSTORE. The EVM stores data in 32-byte words, and each word that is stored requires a SSTORE operation. To set the value of a 32-byte word from zero to a non-zero value requires 20,000 gas. Changing a non-zero value using SSTORE costs 5,000 gas.

Another major factor in gas efficiency is Big O runtime and storage complexity of our functions. Most mint calls require some amount of operations to occur for each token id minted. This can be noted as O(n) where n is the number of token ids minted. Thus, gas cost to mint typically scales linearly, no matter how much you optimize and reduce the amount of operations per token id.

Our goal in this article is to write a mint function with constant runtime and storage complexity, or O(1).

Let’s start with the standard mint implementation from open-zeppelin. This implementation mints a single token at a time.

We will assume a simple case where _beforeTokenTransfer and _afterTokenTransfer are un-implemented and don’t contribute to the gas cost. As you can see, the implementation performs the following operations for every token id.

  • Checks for existence of token id (SLOAD)
  • Sets the _owners mapping for the token id (SSTORE)
  • Emits the Transfer event (LOG)

Note that updating the _balances mapping is not tied to each token id specifically. We can do better with a simple _batchMint implementation, which gives us two optimizations.

First, we have saved the 21,000 gas overhead that is the required minimum for any Ethereum transaction. If we mint 100 tokens in one transaction vs 100 tokens in one hundred transactions, we will save 21,000 x 99 = 2,079,000 gas.

Second, we are saving 5,000 gas per balance update (remember that an SSTORE for a non-zero value only costs 5,000). This saves us another 5,000 x 99 = 495,000 gas.

This is a good start, but it doesn’t change the fact that minting is still an O(n) operation instead of O(1). Our problem is still that we perform an existence check for each token id, store the owner it was minted to, and emit a transfer event per token id.

Let’s start with the Transfer event. It is required by EIP-721, but there is another approved EIP that can help us here. EIP-2309 to the rescue. It provides a ConsecutiveTransfer event so that we can emit a single event for the entire mint call regardless of the number of token ids minted.

Time to modify our _mintBatch function.

Two issues remain, the existence check for each token and the storage operation to set the owner of each token id. Let’s start by tackling the heavy hitter, storage.

If we structure our project in such a way that we can mint to a single wallet instead of individual wallets, we actually skip the storage operation entirely by modifying our contract to assume the initial owner is some default wallet that we specify. In order to handle transfer and burn operations properly, we have to maintain a flag that tells us whether or not a token id has ever been transferred or burned before.

Here we have created two structs. Solidity helps us because it tightly packs fields in a struct into a single 32-byte word when possible. Thus, TokenOwner will take a single storage slot instead of two that would be required if transferred and ownerAddress were stored separately. The CollectionStatus struct allows us to store all the global state about our collection in a single storage slot.

We can now replace the ownership mapping(uint256 => address) with an optimized data structure that maps the token id to an owner and transferred flag.

A few other things in our contract need to change related to getting and setting ownership and checking for existence. To do so, we can simply override the base behavior of a few functions from our ERC-721 base contract, which is the open-zeppelin implementation with a few tweaks to prepare it for our optimization.

The key here is that when we mint, we can leave the storage of the owner in the default zero state, which indicates that transferred flag is false. As long as the transferred flag is false and we know that the token id is within the range of token ids we minted, we can infer that the token is owned by the default owner wallet. If the token id is outside the range of ids we minted, we know that it doesn’t exist.

If a token is burned, its owner address becomes address(0), but its transferred flag is set to true. We can use this information to understand that burned tokens no longer exist.

Finally, if the transferred flag is true, but the owner address is populated, we know who the owner is explicitly.

Putting this all together allows us to re-write our _mintBatch function as follows.

Because we can mint all of our collection’s NFTs in a single transaction, we can assume we will mint once and that all token ids increment sequentially starting at 1. This allows us to skip our existence checks for each token id.

We have successfully eliminated all operations on individual token ids! We have achieved O(1) complexity and a mint cost of 52,000 gas whether you are minting a 1/1 NFT or 1,000,000 NFTs. Measurements of gas used to mint/transfer/burn/approve can be found alongside the complete source code at our fixed cost minting Github repo.

The trade-off of this approach is that eventually the first transfer or a burn performed on each token id is going to result in a SSTORE operation of 20,000 gas. In the case of a fully on-platform centralized NFT exchange, this gas cost may never be seen unless users withdraw their NFTs to their own wallets. In the case of a fully de-centralized on-chain project, have we really saved anything?

The answer is YES!!! With the use of an air-dropper contract distribution of the tokens minted during the sale can be performed in batches of several hundred at a time. The default wallet can simply approve the air-dropper contract for all tokens. At that point it can transfer several hundred token ids to their intended users in larger transactions. Recall that this saves 21,000 gas per transfer — which more than offsets the 20,000 gas penalty for the first transfer of each token.

If you made it this far, thanks for reading our post! This was an over-simplified version of the contract, but we hope you to take what you have learned and apply it to your own custom smart contracts and move the NFT community forward. Do you want to work on interesting problems like this? Join our team! Let’s Get Nifty!

--

--

Nathan Gang
Nifty Gateway

Smart Contract Engineer | Nifty Gateway — Engineering and architecting software since 2007, developing Smart Contracts since 2017.