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

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();
}