Stop Using Merkle Roots For Whitelist. Use ECDSA Instead

by Simon, Co-Founder / CTO

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

More articles

creating a proxy factory using clones

we explore how to create a commercial grade proxy factory using clones.

read more

an idea for a decentralized intellectual property and licensing source

a decentralized intellectual property and licensing source that is open source and free to use.

read more

tell us about your project

our offices

  • usa
    miami, fl
  • usa
    new york, ny