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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 fortotalSupply
. If you are not counting burns, you could have an inaccuratetotalSupply
.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.
|
|
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.
|
|
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.
|
|
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.
|
|
You’ll remember our naive (and slow) balanceOf
function that loops over all the token owners and computes a balance:
|
|
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.
|
|
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 computebalanceOf
- 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)!
|
|
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:
- Degenerate/Regenerate - etherscan code by 0x420
- Caked Apes - etherscan - code by nervous.eth
- GEMMA BY TRISTAN EATON- etherscan - code by 0x420
- WVRPS - etherscan - code by nervous.eth
- and a few that haven’t dropped yet
You can email the 0x420 team if you are interested in working with them by emailing [email protected]
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!