How we set out to create a low gas NFT and wound up understanding open zeppelin’s design decisions.**

TL;DR x2: We created a nice ERC721 implementation that is super gas friendly, has been battle tested by some big drops and is working great. You are welcome to use it and contribute to it on github: https://github.com/generalgalactic/ERC721S

We strongly believe that you can have a gas efficient ERC721 and maintain compatibility, composability and future proofing.

The bottom line is that Open Zeppelin has created an amazing set of contracts that do an amazing thing. If you need a fully functional ERC721 without any tradeoffs (besides increased gas costs), use their contracts. If you are willing to make some tradeoffs to lower the amount of gas paid by your users, then check out our new ERC721S base contract and read on.

TL;DR: Everything is a trade off - but some trade offs are bad for the future.

Ugh. I HATE GAS!! - Gauri Sharma, COO General Galactic Corporation

We all hate gas. It’s an important part of the Ethereum ecosystem, but it can be super annoying. It’s often a deal-breaker for new users trying to buy art from their favorite artist, or it’s just another reason not to pull the trigger on a drop that you have been waiting for—as i write this, gas is 233 gwei and I am burnt up about paying that extra bit to get my cool new NFT!

We are lucky to have been able to collaborate on this project with long time friend Jacob DeHart from 0x420. It was fun to hack together again. ;)

Thanks to a bunch of people who helped out with reviewing code, reviewing this post and generally being super supportive around building a gas friendly ERC721. Specifically, we want to thank @shazow for always being our Solidity sounding board, and @reza for helping with test coverage. Also thanks to 0x420 and Nervous.eth for using the ERC721S contract on drops and for being part of the galactic community.

Let’s make it better!

It’s no surprise that one of the top complaints users have about using Ethereum are the gas fees that must be paid to execute certain transactions against a contract. The gas fees required to execute a certain function (such as mint) are proportional to the complexity of that function. And this is just one dimension of the issue. The price of each gas unit can fluctuate minute-by-minute, driven by the utilization of the Ethereum ecosystem.

While we can’t do much directly about the the utilization and capacity of the Ethereum blockchain, we might be able to do something about the way developers who write Solidity contracts prioritize gas efficiency for the end user.

After talking with a few friends and reading some posts about making gas-efficient ERC721 contracts, we decided to survey the landscape. We found that the current ideas around gas cost mitigation were not super helpful.

A lot of the thinking we found was focused on the totalSupply function and removing functionality that seemed unused or unhelpful.

We jumped in and started playing around with these contracts to understand the tradeoffs involved with removing core ERC721 functionality and how those changes effect gas costs.

Our primary worry was that we might be able to create a gas friendly contract today, but lose some aspects of composability and compatibility in the future. NFTs are still super early and the utility that everyone is shouting about is not clearly defined. We didn’t want to make a short-term optimization that hurts our ability to achieve some magical future (DeFi for NFTs, composability building upon already held NFTs, etc).

Getting Started

We started with the base OpenZeppelin ERC721 contract.

For those who aren’t familiar, OpenZeppelin is the best place to find reference implementations of various ERC standards. OZ is the boss that you have to defeat to get to the next level.

One thing about OpenZeppelin, though, is that they are thinking in much broader terms than most Solidity developers. We developers are often thinking more about tomorrow’s drop and OZ is thinking about “will this NFT work in 10 years?”—a perspective I appreciate.

Unfortunately, the ERC721 reference contract provided by OZ is not very gas friendly. It would appear to have several aspects that could be tweaked to make it far more efficient. Or so we thought.

Let’s mint

We started by making modifications in the mint function. It seemed to be the easiest way to make minting more efficient. We immediately saw improvement.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/**
 * OpenZeppelin's Mint function
 */
function _mint(address to, uint256 tokenId) internal virtual {
    require(to != address(0), "ERC721: mint to the zero address");
    require(!_exists(tokenId), "ERC721: token already minted");

    _beforeTokenTransfer(address(0), to, tokenId);

    // Writes cost gas
    _balances[to] += 1;
    _owners[tokenId] = to;

    emit Transfer(address(0), to, tokenId);

    _afterTokenTransfer(address(0), to, tokenId);
}

Let’s tear it all out. Here’s what our optimized minting function looks like. Firstly, we’ve omitted the before and after hooks that can be overwritten by custom implementations. The most impactful change, however, is the removal of the _balances and _owners mappings their replacement with a single _tokens array holding ethereum addresses.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/**
 * Our naive optimization of the mint function
 */
function _mint(address to) internal virtual {
    require(to != address(0), "ERC721: mint to the zero address");

    _tokens.push(to);

    emit Transfer(address(0), to, _tokens.length - 1);
}

The idea is that by minimizing the amount of writes performed during a user’s execution of the mint function, we can defer the computation of certain functions until they’re called–read operations are dramatically less expensive in all senses of the term and can often be computed for free.

 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
  ERC721S
    Minting
      ✓ Can Mint 1 (186ms)
      ✓ Can Mint 100 (495ms)

  StandardOZERC721E
    Minting
      ✓ Can Mint 1 (92ms)
      ✓ Can Mint 100 (740ms)

·-----------------------------------|----------------------------|-------------|-----------------------------·
|       Solc version: 0.8.10        ·  Optimizer enabled: false  ·  Runs: 200  ·  Block limit: 30000000 gas  │
····································|····························|·············|······························
|  Methods                                                                                                   │
······················|·············|··············|·············|·············|···············|··············
|  Contract           ·  Method     ·  Min         ·  Max        ·  Avg        ·  # calls      ·  usd (avg)  │
······················|·············|··············|·············|·············|···············|··············
|  ERC721S            ·  mint       ·           -  ·          -  ·      73794  ·            2  ·          -  │
······················|·············|··············|·············|·············|···············|··············
|  ERC721S            ·  mintMulti  ·           -  ·          -  ·    2583755  ·            2  ·          -  │
······················|·············|··············|·············|·············|···············|··············
|  StandardOZERC721E  ·  mint       ·           -  ·          -  ·     123369  ·            2  ·          -  │
······················|·············|··············|·············|·············|···············|··············
|  StandardOZERC721E  ·  mintMulti  ·           -  ·          -  ·   11560743  ·            2  ·          -  │
······················|·············|··············|·············|·············|···············|··············
|  Deployments                      ·                                          ·  % of limit   ·             │
····································|··············|·············|·············|···············|··············
|  ERC721S                          ·           -  ·          -  ·    2475101  ·        8.3 %  ·          -  │
····································|··············|·············|·············|···············|··············
|  StandardOZERC721E                ·           -  ·          -  ·    2768871  ·        9.2 %  ·          -  │
·-----------------------------------|--------------|-------------|-------------|---------------|-------------·

All the minting, 60% of the gas!

We collectively did a few victory laps shouting “HOLY SHIT LOOK AT ALL TEH GAS WE SAVED.” The discord video chat was on fire!

There are Tradeoffs

This all sounds great except that there are nuanced tradeoffs that have been made by making this change. The default implementation allows for entities to mint any arbitrary token ID (as long as its a valid token ID and hasn’t already been minted). Due to our changes, you can only mint the next token in the sequence, something that’s not so uncommon in the NFT space.

Chain gateways like Infura, Alchemy and others can and will compute the result of a read-only function for you. Most, if not all, have a maximum amount of time they’ll wait for a function to complete.

Often, read-only functions (views, in Solidity parlance) will simply fetch a value from it’s contract state and return it, resulting in a very fast execution time.

But because we are deferring the computation of the results of functions such as balanceOf until call-time, we necessarily have a hard limit on the amount of computation we must do.

In each of these functions we must walk the entire _tokens array, performing what is essentially a filter operation across this set of tokens. In the case of balanceOf we are performing a simple map across that filtered set to produce a single integer value.

It’s important then to know that the amount of computation we must do is directly proportional to the amount of addresses stored inside the _tokens array.

We did some experiments and found that at a level somewhere around 10,000 tokens, most gateways would timeout while executing the function call.

1
2
Can Mint 10k:
     Uncaught TransactionExecutionError: Transaction ran out of gas

All that being said, if your project can be successful with less than 10,000 tokens, you could make use of this technique without much penalty. Timeouts are still a thing, maxgas is still a thing, and these computed functions, if called from a contract (this one or another… composability!) will be very expensive!

balanceOf function becomes slower and more costly as each token is minted

The balanceOf(address owner) function on an ERC721 contract answers the question “How many tokens does a particular address own”. Unfortunately, with our changes, for each token minted, this function gets slower and slower. Perhaps this is acceptable, but beyond some volume of tokens, this implementation will become unworkable.

We would instead like to have fast read functions if at all possible. We peeked in to see how OZ was doing it and saw they were just using a mapping object. This particular mapping maintains an association between an address and a number. That number represents the number of tokens any given address owns. When a new token is minted, the new owner’s value in this structure is incremented by one. The same thing happens when a token is transferred to a new owner. In that case the previous owner’s value would be decremented and the new owner’s value would be incremented.

These arithmetic operations are simple, but do incur an additional cost to the deploy cost and the fees paid by users calling the mint function.

1
2
3
4
function balanceOf(address owner) public view virtual override returns (uint256) {
    require(owner != address(0), "ERC721: balance query for the zero address");
    return _balances[owner];
}

That _balances mapping makes the read calculation super fast—worth it to add a small cost to mint.

totalSupply is even slower and/or broken

The totalSupply function is another view function important to contract composability and interoperability.

Our first cut of totalSupply with our modifications to mint is again, unnecessarily slow. This is because we must account for the fact that tokens can be burned by users—pulled out of circulation. In practice this means the token’s owner is set to 0x0 in our _tokens array.

There is a very real concept of burning that we believe should be supported. Following OZ’s concept of _burn is our guide. What makes this complicated, is burning a token can mess up your counters for totalSupply. If you are not counting burns, you could have an inaccurate totalSupply.

Burning a token is a confusing concept. Often times a project will say they are going to “burn tokens” and then send already minted tokens to 0x000000000000000000000000000000000000dead (called BurnAddress by OpenSea). This isn’t really burning a token. It is just transferring the token to an address that isn’t accessible.

Thus we must look at every address in the _tokens array and filter out all the burned tokens (tokens owned by 0x0) and finally, increment a supply counter for those tokens owned by real addresses.

1
2
3
4
5
6
7
8
9
function totalSupply() public view virtual override returns (uint256) {
    uint256 supply = 0;
    for (uint256 i = 0; i < _tokens.length; i++) {
        if (_tokens[i] != address(0)) {
            supply += 1;
        }
    }
    return supply;
}

As you might expect, this makes totalSupply slow and totalSupply shouldn’t be slow. We shouldn’t have to walk the entire _tokens array. Accounting for burned tokens is the only reason we have to do this, so we came up with an alternate solution that lets us calculate totalSupply much easier and also account for burned tokens.

When a burn operation is performed on a token, we’ll continue to re-assign ownership of that token to 0x0. However, we’ll also perform another operation: incrementing a counter variable named burnCount. This burn counter starts life initialized to zero and every time a user burns a token, this value will be incremented by one.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function _burn(uint256 tokenId) internal virtual {
    address owner = ERC721F.ownerOf(tokenId);

    // Clear approvals
    _approve(address(0), tokenId);
    _burnCount++;
    _tokens[tokenId] = address(0);

    emit Transfer(owner, address(0), tokenId);
}

This adds a bit of cost in gas fees for the user performing the burn, but this trade-off was considered acceptable. We retain the composability and speed we would want for totalSupply, which is now just a single subtraction operation.

1
2
3
function totalSupply() public view virtual override returns (uint256) {
    return _tokens.length - _burnCount;
}

In summary, our small change made minting way more gas efficient but transferred that cost into important read functions like balanceOf and totalSupply. These changes also set hard limits on how many tokens we could reasonably mint. We have essentially broken certain functions or made them functionally unusable.

In some cases, these might be reasonable trade offs if your users are particularly fee sensitive. Maybe your project simply has no use for certain functionality like tokenByOwner, you’ll never call it and so its no big deal that it doesn’t work properly.

In some cases these trade-offs will be simply unacceptable. In that case you and your users would have to accept the higher gas fees in exchange for the extra functionality and scope.

Bummer.

Ok. fine! you win OZ

With that sorted we decided to look at other places we could improve gas costs.

We had added back the _balances mapping that makes balanceOf so speedy. The cost is not insignificant, but balanceOf looping over all the tokens for each execution was untenable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function _mint(address to) internal virtual {
    require(to != address(0), "ERC721: mint to the zero address");

    uint256 tokenId = _tokens.length;
    _beforeTokenTransfer(address(0), to, tokenId);
    _balances[to] += 1;
    _tokens.push(to);

    emit Transfer(address(0), to, tokenId);
}

You’ll remember our naive (and slow) balanceOf function that loops over all the token owners and computes a balance:

1
2
3
4
5
6
uint256 balance = 0;
for (uint256 i = 0; i < _tokens.length; i++) {
    if (_tokens[i] == owner) {
        balance += 1;
    }
}

This full loop over the entire set of token owners would break composability, especially for contracts with large sets of tokens.

Functions like our balanceOf can also be called from other functions in our contract and even from external contracts. This function is a fairly important part of the ERC721 API. And finally, on-chain calls to these functions will consume unpredictable amounts of gas and could even potentially time out, frustrating attempts at integration and composability.

So instead we reverted our change back to the OZ implementation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function balanceOf(address owner)
    public
    view
    virtual
    override
    returns (uint256)
{
    require(
        owner != address(0),
        "ERC721: balance query for the zero address"
    );
    return _balances[owner];
}

So currently we stand at the following changes:

  • Added a burn counter
  • We increment the burn counter within the burn function.
  • Retain the _balances mapping to efficiently compute balanceOf
  • The mint function is now less efficient than the naive implementation, and is starting to look more and more like OZ’s implementation, only with an array instead of a map.
  • The tokenOfOwnerByIndex function is still too expensive to reliably be called by another contract
  • The tokenByIndex function is removed completely

About those tradeoffs

This exercise really highlighted the fact that you must always go into a Solidity project, and especially an NFT project, with well-defined limits and parameters.

It might be important that your contract implement every inch of the ERC721 API. Your project might require a tried and true path to implementation. The users of your distributed app may place a high priority on knowing your product is built on battle-tested contracts. In those cases OZ is pretty damned efficient and perhaps the increased gas fees are negligible.

If your project needs to have greater than 7,000 tokens, the few places where we’re still looping may be too slow. This sets a hard limit on what sorts of project can make effective use of these optimizations.

We are building holistic experiences that include web apps, dapps, smart contracts and other parts of the NFT ecosystem. A number of these methods are used to support our NFT experiences. For instance, showing what NFTs a signed in DAPP user owns.

If you don’t have these requirements or can’t imagine a need for it in the future, you can skip these changes and save some gas.

If your project requires non-sequential token identifiers, you can’t use this approach either.

If we also desperately need tokenOfOwnerByIndex to be speedy and composable, that will only work with this code for small sets of tokens.

It is worth noting that OZ has obviously thought through a lot of these issues. If you are looking for the most compatible solution in the ERC721 space - we would recommend using the OZ ERC721 implementations. If you are willing to make some compromises—then these tradeoffs are probably OK. Remember, DYOR and test test test.

However, if we’re ok with those tradeoffs

spoiler alert: we usually are

With these approaches our experiments have shown anywhere from 40 to 70% cheaper minting fees, without sacrificing on composability, functionality, or being terrible Ethereum citizens.

Other approaches

There are other approaches that are worth looking into:

Batch pre-mint for efficiency of scale and ability to time gas

Wait for gas to be inexpensive and bake it into the mint (which really becomes just transfer) price.

Experiment w/ roll-ups or side chains?

Perhaps Ethereum’s mainnet is not the right place for your NFT to live! Polygon can be used for a full featured L2 chain and you can bridge to mainnet if you need to.

ImmutableX is nice, but lives in a different world completely. Some zkRollup stuff is starting to look nice, but most limit NFTs to flat files, not fully functional like you’d see on mainnet.

Other options that are worth considering

Finally, there are some good projects that offer another view of ERC721 token efficiency:

  • Solmate: Modern, opinionated, and gas optimized building blocks for smart contract development.
  • ERC721A: A fully compliant implementation of IERC721 with significant gas savings for minting multiple NFTs in a single transaction.

Here is a comparion of the other two (along with ERC721Enermerable and ERC721S). Solmate is the best (71304 for 1 mint, 2564221 for 100 mints), ERC721A is best for multiple mints (94868 for 1 mint, 321167 for 100 mints), and ERC721s isn’t too bad (73794 for one mint, 2583755 for 100)!

 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
·------------------------------|----------------------------|-------------|-----------------------------·
|     Solc version: 0.8.10     ·  Optimizer enabled: false  ·  Runs: 200  ·  Block limit: 30000000 gas  │
·······························|····························|·············|······························
|  Methods                                                                                              │
·················|·············|··············|·············|·············|···············|··············
|  Contract      ·  Method     ·  Min         ·  Max        ·  Avg        ·  # calls      ·  usd (avg)  │
·················|·············|··············|·············|·············|···············|··············
|  ERC721A       ·  mint       ·           -  ·          -  ·      94868  ·            2  ·          -  │
·················|·············|··············|·············|·············|···············|··············
|  ERC721A       ·  mintMulti  ·           -  ·          -  ·     321167  ·            2  ·          -  │
·················|·············|··············|·············|·············|···············|··············
|  OZERC721      ·  mint       ·           -  ·          -  ·     123369  ·            2  ·          -  │
·················|·············|··············|·············|·············|···············|··············
|  OZERC721      ·  mintMulti  ·           -  ·          -  ·   11560743  ·            2  ·          -  │
·················|·············|··············|·············|·············|···············|··············
|  ERC721S       ·  mint       ·           -  ·          -  ·      73794  ·            2  ·          -  │
·················|·············|··············|·············|·············|···············|··············
|  ERC721S       ·  mintMulti  ·           -  ·          -  ·    2583755  ·            2  ·          -  │
·················|·············|··············|·············|·············|···············|··············
|  SOLMATE       ·  mint       ·           -  ·          -  ·      71304  ·            2  ·          -  │
·················|·············|··············|·············|·············|···············|··············
|  SOLMATE       ·  mintMulti  ·           -  ·          -  ·    2564221  ·            2  ·          -  │
·················|·············|··············|·············|·············|···············|··············
|  Deployments                 ·                                          ·  % of limit   ·             │
·······························|··············|·············|·············|···············|··············
|  ERC721A                     ·           -  ·          -  ·    2826526  ·        9.4 %  ·          -  │
·······························|··············|·············|·············|···············|··············
|  OZERC721                    ·           -  ·          -  ·    2768871  ·        9.2 %  ·          -  │
·······························|··············|·············|·············|···············|··············
|  ERC721S                     ·           -  ·          -  ·    2475101  ·        8.3 %  ·          -  │
·······························|··············|·············|·············|···············|··············
|  SOLMATE                     ·           -  ·          -  ·    1729768  ·        5.8 %  ·          -  │
·------------------------------|--------------|-------------|-------------|---------------|-------------·

It is awesome to see people solving this problem.

In practice

We have collectively used this contract in a few recent drops and have seen it used in others. Here are the drops that have successfully used ERC721S:

You can email the 0x420 team if you are interested in working with them by emailing oi.024x0@0x420.io

So far, we’ve received a lot of feedback that the drops were noticibly more gas efficient and in many cases, much cheaper than the buyers expected.

You can use it as well!

We have a github repo (generalgalactic/ERC721S) that you can fork, contribute to, and participate in. We would love to see what you are building!

Please let us know if you are planning on using ERC721S and we’ll add to the list.

A Success!