Stop Using Merkle Roots For Whitelist. Use ECDSA Instead
With the rise of NFTs, the need for a whitelist has become more important than ever. This article explains why Merkle roots are not the best solution for whitelists and how to use ECDSA instead.
Below is a classic example of how to use the Merkle root to verify a whitelist.
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "erc721a/contracts/extensions/ERC721AQueryable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract MerkleRootERC721AContract is ERC721AQueryable,Ownable {
using ECDSA for bytes32;
error EmptyRoot();
error AlreadyMinted();
error SignerCannotBeZeroAddress();
error InvalidSignature();
error InvalidProof();
bytes32 private whitelistRoot;
address private signer;
constructor(bytes32 root,address _signer)
ERC721AQueryable("MerkleRootERC721AContract", "MRE721A")
{
if(root == bytes32(0)) revert EmptyRoot();
if(_signer == address(0)) revert SignerCannotBeZeroAddress();
whitelistRoot = root;
signer = _signer;
}
function setRoot(bytes32 _root) external onlyOwner {
if(root == bytes32(0)) revert EmptyRoot();
whitelistRoot = _root;
}
function setSigner(address _signer) external onlyOwner {
if(_signer == address(0)) revert SignerCannotBeZeroAddress();
signer = _signer;
}
function whitelistMintWithMerkleRoot(bytes32[] calldata proof) external {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
if(!MerkleProof.verify(proof, whitelistRoot, leaf)) revert InvalidProof();
if(_numberMinted(msg.sender) > 0) revert AlreadyMinted();
_mint(msg.sender, 1);
}
function whitelistMintECDSA(bytes calldata signature) external {
bytes32 hash = keccak256(abi.encodePacked(msg.sender));
if(hash.toEthSignedMessageHash().recover(signature) != signer) revert InvalidSignature();
if(_numberMinted(msg.sender) > 0) revert AlreadyMinted();
_mint(msg.sender, 1);
}
/*
This is Just a simple example of a
whitelist mint function on an ERC721 contract using a merkle root.
*/
}
Why Use ECDSA Over Merkle Roots?
There are several reasons why not to use a merkle root to whitelist in production. Let's list them all out and talk about them.
1.Higher Gas Fees
- The gas fees for a merkle root are much higher than a simple ECDSA signature. -Check out this article for more information on gas fees for merkle root vs ECDSA. https://medium.com/donkeverse/hardcore-gas-savings-in-nft-minting-part-2-signatures-vs-merkle-trees-917c43c59b07
2.Not Scalable
- Merkle roots are stored on chain at the storage level. This has multiple implications, but the most important one is that you cannot easily add wallets to your whitelist sale.
- If you want to add a wallet to your whitelist sale, you would have to create a new merkle root and update your contract. This is not scalable.
- With ECDSA, you can add wallets to your whitelist sale by simply signing a message your signer and adding it to the backend.
3.Not Easy To Use
- Merkle roots are not easy to use. You have to create a merkle root, store it on chain, and then verify it on chain.
- With ECDSA, you can simply sign a message and verify it on chain.
Examples of Backend Code For ECDSA
import {ethers} from 'ethers';
import * as fs from 'fs';
const whitelistWallets:string[] = []
const privateKey = process.env.PRIVATE_KEY;
const provider = new ethers.providers.JsonRpcProvider(process.env.RPC_URL);
const signer = new ethers.Wallet(privateKey, provider);
async function signWallet(wallet:string):Promise<string> {
//We first checksum the wallet address to make sure it is valid, this will throw an error if it is not valid.
const checksummedWallet = ethers.utils.getAddress(wallet);
const message = ethers.utils.toSolidityKeccak256(['address'], [checksummedWallet]);
//Make sure to arrayify the message
const signature = await signer.signMessage(ethers.utils.arrayify(message));
return signature;
}
type WhitelistWallet = {
wallet:string,
signature:string
}
async function getAllSignatures():Promise<WhitelistWallet[]> {
const signatures:WhitelistWallet[] = [];
for(let i = 0; i < whitelistWallets.length; i++) {
const wallet = whitelistWallets[i];
const signature = await signWallet(wallet);
signatures.push({
wallet: ethers.utils.getAddress(wallet),
signature
})
}
return signatures;
}
async function saveSignaturesLocally() {
const signatures = await getAllSignatures();
fs.writeFileSync('./signatures.json', JSON.stringify(signatures, null, 4));
}
if(require.main === module) {
saveSignaturesLocally();
}