This article compares three popular mechanisms for allowing only selected addresses to buy tokens or mint NFTs: storing addresses in a mapping, signing addresses with a private key, and using Merkle proofs, with a focus on gas cost efficiency.
Abstract
The article begins by introducing three popular mechanisms for allowing only selected addresses to buy tokens or mint NFTs: storing addresses in a mapping, signing addresses with a private key, and using Merkle proofs. It then explains the mechanics of Merkle trees and public signatures. The author conducted experiments to determine the best method from a gas cost perspective, using the solidity compiler with an optimization level of 1000 and Hardhat to measure gas costs. The results show that using mapping is the cheapest, but each address added to the presale costs 20,000 gas of storage. Merkle Trees are only efficient compared to signatures when there are 127 or fewer addresses in the Merkle Tree. The article concludes that signatures should be used for presale and airdrops with over 127 participants, while Merkle trees should be used for smaller presales.
Bullet points
Three popular mechanisms for allowing only selected addresses to buy tokens or mint NFTs are introduced: storing addresses in a mapping, signing addresses with a private key, and using Merkle proofs.
The mechanics of Merkle trees and public signatures are explained.
The author conducted experiments to determine the best method from a gas cost perspective, using the solidity compiler with an optimization level of 1000 and Hardhat to measure gas costs.
The results show that using mapping is the cheapest, but each address added to the presale costs 20,000 gas of storage.
Merkle Trees are only efficient compared to signatures when there are 127 or fewer addresses in the Merkle Tree.
The article concludes that signatures should be used for presale and airdrops with over 127 participants, while Merkle trees should be used for smaller presales.
Hardcore Gas Savings in NFT Minting (Part 2): Merkle Trees vs Signatures
Part one of this series is here. If you are seeing a paywall, click this link.
When tokens are airdropped, or private sales are conducted (sales where only selected addresses are allowed to purchase a token or mint an NFT), there are three popular mechanisms for allowing only a selected list of addresses to buy:
storing the addresses in a mapping
signing the addresses with a private key and verifying the signature on-chain
using Merkle proofs
Here is what the alternatives look like in solidity:
In the first function, we check if the caller is a member of the allowList mapping. In the second two, the caller sends some proof that msg.sender is permitted to conduct the transaction.
The experiment is simple. Which of these three methods is the best from a gas cost perspective?
Merkle Tree Mechanics
I’m going to assume the reader already knows how Merkle Trees work, but I need to do a quick review for the sake of the benchmark. A fantastic explanation can be found here.
A Merkle tree takes a proposed member and a proof that the member is in the set. Each member is a leaf in a (balanced) binary tree, and the proof is the sequence of hashes of the sibling nodes such that the Merkle root can be reconstructed by “hashing your way to the root. ” If the hash at the root matches the hash of the sequences, then the candidate is indeed a member.
It is important to note here is that the bigger the pool of allowed addresses, the longer the proof will be, logarithmic with the height of the tree. So having 32 addresses on an allowed list requires a proof length of 5, 256 requires 8, and 4096 requires a length of 12 etc. For this benchmark, we use 4096 since that is roughly how many addresses would be added to the presale in a typical NFT launch.
Public Signature Mechanics
For a public signature, the address of the buyer is hashed and the hash is signed by a private key. The smart contract knows the public key of the signer, and it uses the hash of the address and the provided signature to know if it really was signed by the private key. If it was, then the user is allowed to purchase. Unlike Merkle trees, this mechanism is not affected by the number of addresses added to a presale or airdrop.
To put these numbers into context, remember that initiating an Ethereum transaction costs 21,000 gas. So you’ll need to subtract that value to see the cost that is attributed to the allow list mechanism. In the table below, we used 4096 addresses for testing.
Predictably, using mapping is the cheapest because all you have to do is read from storage. However, each address to add to the presale costs 20,000 gas of storage. This becomes extremely expensive when there are thousands of addresses. Then there is a risk that the operator spends ETH to add an address to the allowed list, but the customer doesn’t purchase and the expense is lost. If you set the allowedList mapping to zero after the user mints the token, the gas cost will be even lower because the EVM refunds setting storage to zero. The new gas cost in this case is 21,598 down from 23,424. That’s basically unbeatable because a transaction cannot go below 21,000. This will no doubt make for a happier customer, but keep in mind in the grand scheme of things, more Ether gets burned and sacrificed to the miners. It costs 20,000 gas to add an address, but only ~2,000 is saved at mint. Thus, 18,000 gas worth of Ethereum was vaporized. On the other hand, signatures required sacrificing about 8,293 gas worth of Ethereum. This is less wasteful in the long run.
Merkle Tree cost as a function of the number of addresses
Merkle Trees clearly lose when there are 128 addresses or more. They are only efficient compared to signatures when there are 127 or fewer addresses in the Merkle Tree. Because the addresses were randomly generated, there can be a slight variance in the gas measurement.
By experimenting with different addresses (thanks to Honest NFT at Convex Labs for the suggestion!) we found one that saved about 12 gas compared to the typical. It seems that adding leading zeros isn’t the cause of the savings.
One solution that jumps out, but turns out to not be viable is a bloom filter (and its relatives, the XOR filter and ribbon filter). Bloom filters are probabilistic sets where the return “maybe in the set” or “definitely not in the set.” The issue is that an adversary can keep trying different addresses until they find an address that is “maybe in the set.” Even if the bloom filter is set to have an extremely low false-positive rate, the adversary won’t have trouble finding an exception with the aid of a GPU.
Another solution that falls short is using public signatures but with fewer bits, but this would not be able to use the opcode ECRECOVER.
Here are some complicated solutions that might be worth exploring. We have not done any benchmarks, so take with a grain of salt.
zk-snarks. The zero-knowledge isn’t interesting, but the succinct part is. If the gas costs of tornado.cash (which uses zk-snarks) are any indicator, this won’t work but it’s worthy of mention.
Verkle Trees are like Merkle Trees but have a constant size proof
Some tree/trie that allows encoding in bitwise form. If all the addresses could be stored in a long sequence of bits, and proving membership is just traversing the bits a certain way
Patricia Tries or Radix Trees. An address can be encoded as a sequence of hex symbols or a sequence of bits. So if there is a way to encode the tree in bit form, this may be viable.