avatarJeffrey Scholz

Summary

The web content provides an in-depth guide on optimizing gas usage in NFT minting processes on the Ethereum blockchain, focusing on understanding and reducing the costs associated with smart contract operations.

Abstract

The article titled "Hardcore Gas Savings in NFT Minting (Part 1)" delves into the intricacies of managing gas costs in blockchain transactions, particularly within the context of minting Non-Fungible Tokens (NFTs). It begins by illustrating a typical workflow for a mint function, highlighting the importance of gas efficiency in Solidity, the programming language used for Ethereum smart contracts. The author discusses the significance of gas optimization to reduce the financial burden on users during the minting process, especially when gas prices are high. The article explains the gas costs associated with various Ethereum Virtual Machine (EVM) opcodes, emphasizing the high cost of storage operations like SSTORE and SLOAD. It then proceeds to dissect the code of a minting function, identifying areas where gas can be saved, such as by replacing ERC721Enumerable with vanilla ERC721, removing redundant checks, and using the optimizer. The author also touches on security considerations, such as avoiding homemade cryptographic solutions and unnecessary re-entrancy checks. The piece concludes with an invitation to a gas contest for further optimizations and announces a follow-up article on signature schemes versus Merkle trees.

Opinions

  • The author suggests that the ERC721Enumerable functionality, while useful, can lead to unbounded gas costs and recommends implementing such features off-chain when possible.
  • They express a preference for using the external keyword over public for functions that do not need to be called internally, as it is more gas-efficient.
  • The article conveys that while some gas-saving measures may seem minor, they can lead to significant savings when aggregated across many transactions.
  • The author advises against using unchecked blocks and overflow operators unless necessary, as they can introduce security vulnerabilities.
  • There is an opinion that public minting can be effectively gated using signatures to prevent botting, rather than relying on wallet limits, which can be circumvented.
  • The piece criticizes the use of uint64 or similar smaller types instead of uint256 for storage variables, as the EVM casts them to uint256 anyway, incurring extra gas costs.
  • The author promotes the idea of variable packing for deployment efficiency in scenarios where gas optimization is not critical.
  • They also caution against adding unnecessary security checks, such as re-entrancy guards, in functions where they are not needed, to avoid additional gas expenses.
  • The author values community contributions to gas optimization and encourages participation in a gas contest with the incentive of free NFTs for winners.
  • Finally, the author endorses their own educational content on the EVM and Solidity compiler, suggesting that readers can benefit from a deeper understanding of these topics.

Hardcore Gas Savings in NFT Minting (Part 1)

Photo by Gene Gallin on Unsplash

(If you are seeing a paywall, read the store here). If you want to follow along, see this github repository: https://github.com/DonkeVerse/GasContest

Solidity is a very easy language to learn if the developer comes from a C-like language (Java, C, javascript, etc), but managing gas costs in the context of a blockchain is something that appears in very few other domains.

Let’s look at a typical workflow for a mint function in the gist below. A mint function is part of the blockchain backend to a web3 application where the user mints an NFT by clicking the mint button and agreeing to send a certain amount of cryptocurrency.

Here is a typical solidity workflow that mirrors various NFT smart contracts in production that has some very good features

This code was executed in a hardhat environment which reports the gas costs. The rest of the contract isn’t shown for brevity, but it inherits from OpenZeppelin’s ERC721Enumerable contract. If you wish to follow along, you can use the repository here as a starter.

The function totalSupply() isn’t defined in the gist above, it’s part of the functionality inherited from ERC721Enumerable. It does exactly what the name suggests, return the total supply of all the tokens minted so far.

Here we will explain some aspects of the code that are less obvious. The first is the line msg.sender == tx.origin. When a wallet directly calls the mint function, msg.sender will be the wallet of the address. However, if a smart contract calls publicMint, msg.sender will be the address of the smart contract. In that case, tx.origin will be the wallet that initiated the chain of events leading to the smart contract calling publicMint. When a smart contract makes a call, tx.origin won’t equal msg.sender. This prevents smart contracts from minting.

Second, we see a “require” statement that consists of toEthSignedMessageHash and recover. This will be explained fully later. But the idea is we sign potential customers with a secret key and return the signature. The smart contract knows the public address of the secret key (stored in the “publicMinting” address variable). The smart contract will check if the signature matches the public address in question. That way, they know the private key owner authorized the transaction. This serves as an anti-bot measure, as we can use a captcha-like interface to ensure humans have a fair chance at minting. More on this later.

The rest of the code should be self-explanatory.

Hardhat has a nice feature of telling you how much a function invocation costs in terms of gas, that is

REPORT_GAS=true npx hardhat test

The unit test in the code (see the repository linked earlier), mints from the same address twice. The first mint costs 160,995 gas and the second mint costs 132,695.

What does this mean in a practical sense? Gas costs (in dollars) can be calculated with the following formula:

Gas $ = Gas Cost ÷ 1 Billion × Gas Price in Gwei × ETH Price $

Gas Price varies with demand, but at this time of writing, it costs 80 Gwei according to ethgasstaion.info. Ethereum costs $3,600 at this time of writing, so if we multiply everything together (160,995 ÷ 1 Billion × 80 × 3,600) we get a gas price of $46.

For those of you accustomed to $100 mints, remember that 80 Gwei is somewhat low by recent standards and it can easily go to 150 Gwei. If Ethereum is over $4,000, the mint cost will easily be into the three figures.

So if we want to lower the gas cost, we can either wait for times when the gas price is low, or we can modify the contract to be as efficient as possible, which of course is the focus of this article.

Understanding the EVM

When solidity is compiled, it gets turned into a sequence of op codes just like any normal compiler. Each of these op codes has an associated gas cost. Trail of Bits has compiled a nice table here which the reader is encouraged to skim. The relative costs of the opcodes are sensible. For example, taking a hash with KECCAK256 costs at least 30 gas, but multiplying two numbers costs only 5 gas. Makes sense right?

To get the gas down, we need to focus on the really hefty operations: SSTORE (20,000 gas) and SLOAD (800 gas). These operations are writing and reading from blockchain data respectively. The Ethereum designers made these operations expensive because once something is written to the blockchain, it could possibly be stored there for the rest of time, so the high cost is to disincentivize taking up valuable blockchain space. SSTORE has an interesting quirk if you dig into the mechanics of it. Setting a storage value from zero to non-zero costs 20,000 gas. But changing a non-zero value to a non-zero value costs 5,000 gas. That’s why the second mint is considerably cheaper than the first one. In the first iteration, totalSupply increments a storage variable from zero to one, and value changed in alreadyMinted[msg.sender]++ goes from 1 to 2 in the second mint rather than 0 to 1.

Hunting down storage opcodes

We could of course compile to the Ethereum bytecode and look at that, but frankly, that’s not necessary. Let’s look at it line by line and see where we might be reading and writing from storage:

mapping (address => uint256) public alreadyMinted;
bool public enablePublicMint = true;
uint256 constant public PRICE = 0.06 ether;
address private publicMintingAddress;

function publicMint(bytes calldata _signature) external payable {
    require(totalSupply() < MAX_SUPPLY, "max supply"); // READ
    require(enablePublicMint, "public mint enabled");  // READ
    require(msg.sender == tx.origin, "no bots");       
    require(publicMintingAddress ==                    // READ
        bytes32(uint256(uint160(msg.sender)))
            .toEthSignedMessageHash()
            .recover(_signature),
        "not allowed"
    );
    require(alreadyMinted[msg.sender] < 2, "too many"); // READ
    require(msg.value == PRICE, "wrong price");        

    alreadyMinted[msg.sender]++; // READ and WRITE
    _safeMint(msg.sender, totalSupply()); // ??
}

For the _safeMint() function, we’ll need to dig up the OpenZeppelin contract to see what it is doing:

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); // see below
     _balances[to] += 1; // READ AND WRITE
     _owners[tokenId] = to; // WRITE
     emit Transfer(address(0), to, tokenId);
}

Because we are inheriting from ERC721Enumerable, _beforeTokenTransfer does a few more things. During mint, _addToAllTokensEnumeration is called.

function _addTokenToAllTokensEnumeration(uint256 tokenId) private {
      _allTokensIndex[tokenId] = _allTokens.length; // READ
      _allTokens.push(tokenId); // WRITE
}

And so is _addTokenToOwnerEnumeration

function _addTokenToOwnerEnumeration(address to, uint256 tokenId)
    private 
{
        uint256 length = ERC721.balanceOf(to); // READ
        _ownedTokens[to][length] = tokenId;    // WRITE
        _ownedTokensIndex[tokenId] = length;   // WRITE
}

The final score for storage operations is 8 reads and 6 writes which totals to over 120,000 of gas cost! (Ethereum does some caching for storage reads in the same operations, so it’s a bit more complicated than this, but the order of magnitude is correct).

Getting Rid of ERC721Enumerable

The added functionality of ERC721Enumerable is the subject of another article. But essentially, what it allows an external smart contract to do is query the ERC721Enumerable contract for how many tokens a customer owns, and then one by one ask which token ID is owned for each of the user's tokens.

Personally, I’m not a fan of this functionality. There is no theoretical limit to how long the iteration will last (so the gas cost is unbounded). Iteration is free (from a gas perspective) when done off-chain. Nearly all interactions with NFTs happen through a web browser that can use an Ethereum node like Infura or Etherscan to accomplish the iteration.

If you build an NFT game this might be necessary, but you can recover the same functionality by iterating off-chain and forwarding the result to the external smart contract, which can validate that each of those IDs is indeed owned by the address.

Anyway, I digress. Let’s replace ERC721Enumerable with vanilla ERC721 and see what happens.

Since we lose the function totalSupply(), we will need to implement it ourselves

Remember how we said writing from zero to non-zero costs 20,000 gas but 5,000 gas when the value is changed from non-zero to non-zero? That means if we start totalSupply at 1 instead of zero, we’ll easily save 15,000 gas. (We will also need to update the unit test and MAX_SUPPLY to reflect the shift in token Ids).

Wow, we’re down from 160,000 gas to 107,000 gas! That could be over $40 in savings for the end-user depending on market conditions! But there is still room to improve!

Use balanceOf instead of alreadyMinted

If you scroll back up to look at how OpenZeppelin implements the mint functionality, you’ll see the line

_balances[to] += 1;

It turns out we are actually duplicating a read and write by tracking this in a separate mapping. OpenZeppelin provides an interface to this variable via balanceOf which we can call from our code.

The gas cost drops by over 20,000! Makes sense right? We just got rid of a write and a read from storage when we deleted alreadyMinted[msg.sender]++ .

Increasing efficiency of totalSupply

It turns out we are being a bit wasteful with how we manage totalSupply. In the gist above, it is read from storage 3 times and written once. Remember, reading from storage is expensive, so if we instead write it to a local variable, operate on that, and then write back to storage, we can spare ourselves some cost.

That saved 141 gas. The reason the gas savings weren’t tremendous was that the EVM doesn’t charge the full 800 if you read from the same storage variable in the same execution context. Still, over thousands of mints, that’s a good amount of Ethereum saved.

Another thing that may look funny to people is storing totalSupply after calling _safeMint. This is safe to do because if an external contract tries to interact with it, this is blocked by msg.sender==tx.origin. But without that protection, this would be unsafe!

If that’s the case, then why are we incrementing _totalSupply only to subtract one in the next line? Good question! Let’s re-arrange our math!

That saved us 188 gas, which seems like a lot for addition and subtraction. If you look at the opcodes for ADD and SUB (add and subtract), it’s only 3 gas each. But when you compile to bytecode, remember that the EVM has to keep shuffling the right variables around to keep the correct ones on top of the stack. Compiling to bytecode is an exercise for the reader, but you will see simple math actually contains several reads and writes from memory and some PUSH and sometimes DUP instructions. These are individually cheap, but add up quite a bit.

Removing overflow checks

Since solidity 0.8.0, arithmetic overflow protection is included by default. But we already have an overflow guard because we require the total supply to be less than 7777. Thus, under the hood, there are two overflow checks happening which is obviously a waste of gas. Let’s fix that with an unchecked block.

Another 123 gas savings!

Turning on the optimizer

Astute readers may have noted that I haven’t turned on the solidity optimizer yet. This was intentional so I could deep dive into storage, and the optimizer can’t reduce the opcode cost, just issue fewer opcodes. Here it is with the optimizer set to 200.

·-------------------------------|---------------------------|
|         Solc version: 0.8.10  ·  Optimizer enabled: true  ·
································|···························|
|  Methods                      ·                         
···············|················|·············|·············|
|  Contract    ·  Method        ·  Min        ·  Max        ·
···············|················|·············|·············|
|  GasContest  ·  publicMint    ·      66718  ·      83818  

And then set the optimizer to 1000.

·-------------------------------|---------------------------|
|         Solc version: 0.8.10  ·  Optimizer enabled: true  ·
································|···························|
|  Methods                      ·               
···············|················|·············|·············|
|  Contract    ·  Method        ·  Min        ·  Max        ·
···············|················|·············|·············|
|  GasContest  ·  publicMint    ·      66674  ·      83774  ·

The tradeoff is that the deployment will be more expensive with the optimizer set to a higher number. The higher the number, the more the compiler optimizes functions for being used thousands of times rather than hundreds of times. Since we are planning for thousands of mints, and we want to focus the gas savings within the mint functionality, a higher number makes sense.

mint vs safeMint

What is the difference between these two functions? The safeMint function calls mint and checks if the receiver is a smart contract and implements the ERC721Receivable interface.

function _safeMint(
    address to,
    uint256 tokenId,
    bytes memory _data
) internal virtual {
    _mint(to, tokenId);
    require(_checkOnERC721Received(address(0), to, tokenId, _data),
           "ERC721: transfer to non ERC721Receiver implementer");    }

Since we don’t support minting to smart contracts, we can skip this check. Also, remember our discussion earlier about how Ethereum is an all-or-nothing execution? Here you can see the require statement happening after the business logic!

As a result of this change, we save 315 gas. Again, make sure you understand your business requirements. If you expect smart contracts to be minting your NFTs, you should use safeMint instead.

Removing redundant checks for enabled public mint

Although we aren’t duplicating code, we do have overlapping functionality that can be trimmed off.

The signature scheme also allows us to prevent public minting just by setting the signing address to 20 random bytes. (Setting to the zero address to disable public mint is not a good idea because some signature recovery failures result in the zero address according to OpenZeppelin and someone could bypass the gate!). It may also be tempting to save gas by removing the hash function hidden inside toEthSignedMessageHash (more on this later), but OpenZeppelin specifically says not to do this!

Gas savings are fun, but don’t roll your own crypto!

With this check removed, we see a couple thousand in gas savings.

Saving a function call with toEthSignedMessageHash

That said, we can still copy and paste the code from this function to avoid a function call and save a little gas. Technically, we could do the same with the recover()function too, but this affects the readability very badly and only saves a bit of gas. And to be frank, I don’t know the ECDSA algorithm like the back of my hand, so I’ll just leave the code untouched.

That only saved 28 gas. I hope the solidity developers fix this someday. Calling an internal function should be optimized away. Admittedly, solidity financially encourages bad coding practices. Welcome to the early days of new technology I suppose.

redundant operations in ERC721 _mint

Let’s dig out the ERC721 mint functionality again

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);
    _balances[to] += 1;
    _owners[tokenId] = to;
    emit Transfer(address(0), to, tokenId);

}

At this point, we are entering the danger zone. It usually isn't a good idea to modify heavily audited libraries unless you know what you are doing! The following modifications work because our codebase only has one entry to the mint functionality. If there were several ways to mint, there could be transaction order bugs, so only do these changes if you know what you are doing. The gas savings are not huge.

In our case, we don’t need to zero address check because msg.sender is never the zero address. Because the tokenId is always incremented, we don’t need to check if the token exists already (remember, either the mint succeeds and total supply increases or the entire operation reverts). Similarly, _beforeTokenTransfer is not used so we can remove that.

We can also change _balances to be an internal variable rather than a private one so that we can directly access it rather than using a function call. Here is the result

Taking Gas Savings Too Far

It is technically possible to shave off one more storage write in the current form, and some projects do. These solutions replace _balances[to] += 1 and _owners[tokenId] = to with a single array owners.push[to] and then loop over the array to fulfill the balanceOf ERC721 functionality. By replacing two storage writes with one, 20,000 gas can be saved. The problem is that owners can be in the thousands.

A simple function could cost millions in gas. Unbounded loops are a known anti-pattern in solidity. While it is true that perhaps many projects can get away with not ever needed balanceOf called on-chain, we don’t know what kind of decentralized marketplaces will be built in the future. If the decentralized marketplaces can’t interact with the smart contract due to high gas costs, the value of the NFT will suffer. Gas improvements that have this weakness will not be accepted.

We don’t have to speculate about future needs either. We need to know the balance of the wallet before we permit a mint because we don’t want too many wallets to hold one NFT. Someone who comes in at the six thousandth mint is going to have to loop through six thousand addresses to see if they are permitted to mint. That isn’t a workable solution.

What if wallet limits aren’t necessary?

An argument can be made that if someone wants more mints, they can just give their buddies some ETH and have them mint at the same time. Remember, this is a public mint, and the signature is only used for gating to stop botting, not for a presale list. The only way to have a good chance that no single holder has too many pieces is to only do a private sale, but even then people can use multiple identities.

This is of course, not a cut-and-dry issue, but for those that wish to remove the restriction, here are the benchmarks.

Note that nothing prevents someone from minting and then transferring away to reduce the balance and mint again, but someone who is motivated to do this during public mint can more easily just use additional addresses he or she controls to get all the transactions into a single block (a sybil attack). In part 3 we discuss a gas-efficient way to limit per-wallet mints that is immune to people transferring the NFTs away (note this is only meaningful if you have some defense against sybil attacks when addresses are added to the presale list).

Keep in mind that limiting the addresses to 2 at public mint is pretty arbitrary, and may or may not be the right choice for the project.

Don’t use <= or >= Comparison Operators

In the EVM, there is an opcode for less thanand greater than, but not less than or equal to or greater than or equal to. Code that uses the <= or >=operator, then it will be more expensive gas-wise because it will check if the values are less than and also check if the values are equal.

That is why totalSupply < 7778 is preferable to totalSupply <= 7777 and msg.value == PRICE is better than msg.value >= PRICE . If possible, it’s better to shift constants by one rather than use these more expensive operators.

Don’t Use Anything Other Than uint256

In solidity, variable packing is placing small storage variables next to each other so they sit in a single 256-bit slot. For example,

uint64 public var1;
uint64 public var2;
uint64 public var3;
uint64 public var4;

Takes up the same storage space as

uint256 public var;

This is variable packing and can save cost on deployment if you don’t need the full 256 bits.

In the code earlier, however, totalSupply is a uint256 variable. Although you might be able to save gas on deployment by variable packing, the users will have to pay extra. Whenever a uint smaller than 256 (or even a bool) is pulled from storage, the EVM casts it to a uint256 (see more in the docs). This extra casting costs gas, so it’s best to avoid it. For functionality that isn’t as gas-sensitive, variable packing may be a good idea.

Don’t Use “public” If "external” Will Do

Note that the publicMint function is written as

function publicMint(...) external payable {

not

function publicMint(...) public payable {

Unless your contract, for some very unusual reason, needs to call publicMint from inside the contract, don’t use public . The difference between the two is that external doesn’t allow the contract itself to call the function, but public does. Although they accomplish the same thing (allowing outside calls), external is more gas efficient since Solidity doesn’t have to allow for two entry points.

Don’t Use Unnecessary Re-entrancy Checks

Remember, a re-entrancy vulnerability can only happen if you call another contract (like if you explicitly call another contract’s function or transfer Ether). Throughout the mint sequence, no external contract is called and no Ether is sent. I’ve seen a couple of projects add these checks to the mint function where they aren’t needed. It’s good to be security conscious of course, but re-entrancy checks require writing and reading from storage which is expensive as documented earlier, and they don’t actually increase security in this case.

Gas Contest

See any more improvements that can be made? Some improvements have not been published as virtual gas bounty hunt. If you find them, hop onto our discord and discuss them in #dev-talks. channel. We are rewarding free NFTs to the winners (as ranked by gas saved and who found the solution first). The starter repo is here. We hope you will also find improvements that we missed!

We are rewarding all kinds of technical contributions (security, fairness, UX, etc), not just gas savings, so please stop by!

(The gas contest is now closed)

Addendum — Gas Contest Solution

This code can be further improved by replacing bytes32(uint256(uint160(msg.sender))) with bytes32(bytes20(msg.sender)) . Also, the function call _mint can be unwrapped inside the child contract. That is, we can convert the parent variables to internal and copy the function code over:

// _mint(msg.sender, _nextTokenIndex); remove this line
unchecked {
    _balances[msg.sender] += 1;
}
_owners[_nextTokenIndex] = msg.sender;
emit Transfer(address(0), msg.sender, _nextTokenIndex);
        
unchecked {
     _nextTokenIndex++;
}
nextTokenIndex = _nextTokenIndex;

Conclusion

Some of these gas savings result in very small savings, so the developer will have to decide the tradeoffs. Nonetheless, hopefully, this exercise leads to a deeper understanding of the EVM and where gas savings can be found!

In Part 2, we will compare mappings, Merkle trees, and public signatures as mechanisms for allowing selected buyers to purchase. Public signatures are a clear winner (why else would we use it here?) but it’s helpful to know why.

Like what you see? Consider checking out my class on the EVM and the Solidity Compiler here: https://www.udemy.com/course/advanced-solidity-understanding-and-optimizing-gas-costs/?referralCode=C4684D6872713525E349 You can follow me on twitter here: twitter.com/jeyffre

Solidity
Ethereum
Blockchain
Programming
Nft
Recommended from ReadMedium