NOOBPUNKS is a newly released pfp-style project, consisting of 10,000 generated punks, in the style of the NOOBS project. NFT Culture Labs was enlisted to help build the art generator for the project, as well as provide a smart contract implementation using the latest state of the art in gas optimizations. Gas savings was a priority for the project, as the punks are being offered at a relatively inexpensive price, comparable to other punk-derivative projects.
The general approach to develop NOOBPUNKS was the following: stick to well adopted practices, minimize or eliminate access to storage variables where possible, prioritize common cases over uncommon cases, carefully consider what features to include in the contract versus what to do off-chain.
Visit https://www.noobsnft.com/punks/ to mint today!
Public Minting starts on Feb. 16th at 10am CST.
NOOBPUNKS – 10000 generated punks in the style of NOOBS
Click here to view the verified NOOBPUNKS contract
Prioritize the common cases
- Single mint vs. batch minting
The initial decision during the contract development process was whether to start with an ERC721A base contract or to use ERC721P or NFTCultureLab’s ERC721Public as the base contract. While we wanted to use the ERC721Public implementation, the decision ultimately came down to our prediction of the most common use case of the NOOBPUNKS contract: single mints vs. batch mints.
In a scenario where most or all minters are likely to mint single tokens, ERC721Public can shine, as it will match and potentially exceed the gas savings of ERC721A’s mint function. However, once even a handful of batch mints are expected, the savings from ERC721A’s batch minting process will quickly yield drastic savings vs. ERC721Public.
It’s important to take the likely scenarios into account when choosing the base contract that you wish to use. The primary shortcoming of ERC721A, which is it’s lack of burn functionality, has been addressed in a recent changeset, see PR#61. Be sure to check with the Chiru Labs github here and get the latest version of the contract before you use it. (As of the time of this writing, the latest version is 2.2)
- Separate allowlist minting from public minting
Another example of analyzing common scenarios carefully can be seen in the design of our mint functions. Often, contracts will attempt to contain all mint functionality within a single mint function. This is a huge problem, from a gas perspective, because functionality like allow list checks, presale checks, etc. will be executed on all mint calls, for all people, regardless of the phase or the eligibility of the end-user. To make matters worse, these checks will typically involve expensive storage lookups. This results in a double penalty to the most important portion of a project sale – the public minters.
An easy solution to the problem of wasteful allowlist checks, is to seperate them out into their own individual mint functions. A side affect of this decision, is that it makes it easier to unit test your functions, as you just write separate, targeted tests as well. This optimization can be seen in practice in the NoobPunksBase.sol contract file:
By targeting each phase seperately, end users in the phase only pay gas for operations that are entirely relevant to their scenario. This adds a little bit of overhead to the gas used by deploying the contract, but its a small drop in the bucket compared to how much gas is saved when minting.
Following well adopted practices
- Preserve the intentions of ERC721 as best as possible
When developing a contract, and especially when making modifications to a well known contract implementation such as Open Zeppelin’s ERC721 implementation, it is important to do your best to conform to not just the interfaces but also the intended meaning and outcomes of the functionality. Gas optimizations are great, but if totalSupply() is changed to mean totalNumberMinted() or burn() transfers a token to address(1) instead of address(0), people are likely to use the functions provided by your implementation improperly. Worse than that, you run the risk of incompatibilities with marketplaces if you deviate too far from what is commonly expected.
Minimize or eliminate access to storage
Learning the lesson of storage and its impact on gas is an important process for new solidity developers to go through. When gas is no object, the complexity and richness of features that you might want to implement are quite broad, but once you come to realize that the contract will be unaffordable to operate, you quickly will refocus on the objective of making things as streamlined as possible.
- Leverage base class functionality as much as possible
A great example of this optimization is leveraging the “numberMinted” property provided by ERC721A. The numberMinted field is great for keeping track of how many tokens someone has claimed against a claimlist or if you want to make sure each wallet can only mint a set amount during the public sale. You can save a pretty significant amount of gas by leveraging functionality provided by the base class instead of implementing functionality similar to how you might have done it in previous non-gas optimized project.
- Constants instead of storage variables. Have a strategy in your system that allows for unit testing
Constants are an important type of variable to leverage in solidity. When your contract is compiled, the compiler will replace instances of a constant with its value, as if the constant didnt exist. So constants serve a role of looking like a variable, but playing like they are a hardcoded value. This is important, because a constant can allow you to leverage a single value across multiple locations in the code, similar to what you might do with a private variable, but without the corresponding impactful storage gas overhead. The big drawback with the constants approach, is that it makes unit testing more difficult. You either have to provide override functions that use member variables instead of the constants, or you need to replace your variables with constants right before you deploy the contract to mainnet. For NOOBPUNKS, we started by defining as many variables as possible as constants. Those that potentially could be converted to constants, we commented as such. We then created unit tests that tested both the values used during testing, as well as the actual mainnet/to be deployed values. Once we flipped the variables over to constant, we set the unit tests with test values to skip. As long as all the remaining tests are passing, then we were safe to deploy.
- Pack variables where it makes sense.
Because Solidity is built on top of 256bit values, Boolean and Integer variables can typically be packed together without any loss of data. Though this process can be tedious and error prone, it is typically valuable when working with multiple storage variables, as each reduction in a call to a storage variable can save as much as 20k in gas. To make the process easier, NFT Culture Labs has made BooleanPacking.sol available in the open source repository.
- Merkle trees for allowlists
Merkle trees offer an important degree of gas optimization for contracts. The information stored in contract is minimal, a single bytes32 variable is enough to validate thousands and tens of thousands of different wallet addresses. Additionally, the security offered is very high. Anyone attempting to tamper with a leaf or insert a wallet into the tree will be rejected. Additionally, validation of whether a wallet is in the tree, though not as efficient as checking a single signature (as happens with an ECDSA scheme), can be done in linear time, and the complexity of the check scales rather slowly. for instance the difference between an allowlist of 1000 wallets and an allowlist of 2000 wallets, is only one single additional pass through the proof checking loop.
In NOOBPUNKS, the mint functions relying on Merkle trees generally executed in around 100k gas on average. Some function calls dropped as low as 80k gas, and a few with larger batch sizes associated with the mints went as high as 130k. So, using Merkle trees does increase gas impact, but if an allowlist or presale is important to your project, don’t be discouraged to layer one in on top of the ERC721A minting functionality.
Additionally, to make minting with Merkle trees easier, NFT Culture Labs is prereleasing two important new solidity implemenations within the NOOBPUNKS Contract: MerkleClaimList.sol and MerkleLeaves.sol.
MerkleClaimList.sol is a fully self contained library, which allows multiple Merkle trees to easily co-exist in one contract, with all the necessary functionality needed for a fully functioning Merkle tree contained within the library. The real beauty of this library, is it allows you to add support for a Merkle tree onto the contract simply by declaring a new member variable:
// Two fully functioning and independent Merkle Trees residing in the same contract.MerkleClaimList.Root private _claimRoot;MerkleClaimList.Root private _presaleRoot;
Second, is MerkleLeaves.sol, which acts as a companion contract to the MerkleClaimList library. MerkleLeaves.sol provides two handy functions for generating the hashes for two common types of leaves used in a merkle tree: _generateLeaf and _generateIndexedLeaf. The former will generate a leaf for an address value, and the latter will generate a leaf for an address and an index. Two additional methods are provided to expose access to these functions to external callers. Because all 4 of the functions are pure, they can be used in conjunction with any combination of Merkle trees that exist in the primary contract. Both functions will be released in the coming weeks to the NFT Culture Labs open source repository, so be sure to watch and/or star it and check back soon.
Carefully consider Contract features
- Off-chain randomization
- No custom features
The final two points to consider when building your project are two aspects to the same consideration: try to reduce custom coding as much as possible. Whether it is a pseudo-randomly generated (and potentially gameable) aspect of your minting process, or it is a bespoke approach to discounting a purchase or trying to prevent people from claiming multiple pieces in a wallet, all of these features can lead to excess gas consumption at best, and a broken experience or financial liability at worst. So when working with your team to develop the project as a whole, be sure to steer the team towards well practiced solutions and away from any unnecessary custom development. Its best to keep the standard parts standard, that way if you do try to push the boundaries of what is possible, you can focus the bulk of your energy on that effort, and save time where you can re-use what others have already fine-tuned.
Standing on the Shoulders of Giants
This brings me to my final topic, which is that we are all standing on the shoulders of giants. Whether it is the ethereum development ecosystem’s incredible work on the platform, or Open Zeppelin’s reference implementation, or the ingenious enhancements to it created by Pagzi Tech and then Chiru Labs, we all benefit enormously from each other’s efforts. Do your best to instill this collaborative and open spirit amongst your project teams, and make it a point to try to contribute back to the ecosystem however you can. Whether it is providing some new implementation of a Merkle Tree or maybe it is just a simple bug fix to someone else’s open source repo, every bit of progress is valuable, and we wouldn’t be were we are with gas savings if those who came before us hadn’t done just that. Get out there and build something great! GM!
If you want to talk with devs or potentially find people looking to build projects, please join the NFT Culture discord and drop a line in the developers lounge channel.