Checking Whitelisted Addresses on a Solidity Smart Contract Using Merkle Tree Proofs

muratcanyuksel

Murat Can Yüksel

Posted on June 12, 2024

Checking Whitelisted Addresses on a Solidity Smart Contract Using Merkle Tree Proofs

Intro

Hello everyone! In this article, we will first talk about Merkle Trees, and then replicate a whitelisting scenario by encrypting some "whitelisted" addresses, writing a smart contract in Solidity that can decode the encrption and only allow whitelisted addresses to perform some action, and finally testing the contract to see whether our method works or not.

IF you already know about merkle trees and directly start with the hands-on experience, you can skip the Theory part and start reading from the Practice section.

Theory

In the evolving world of blockchain and decentralized applications (dApps), efficient and secure management of user access is paramount. One popular method for controlling access is through whitelisting, where only approved addresses can interact with specific functionalities of a smart contract. However, as the list of approved addresses grows, maintaining and verifying this list in an efficient and scalable manner becomes a challenge.

This is where Merkle trees come into play. Merkle trees provide a cryptographic way to handle large sets of data with minimal storage and computational overhead. By leveraging Merkle trees, we can efficiently verify whether an address is whitelisted without needing to store or process the entire list of addresses within the smart contract.

In this tutorial, we'll dive deep into how to implement a whitelisting mechanism using Merkle trees in Solidity. We'll cover the following key aspects:

Understanding Merkle Trees: A brief overview of what Merkle trees are and why they are useful in blockchain applications.
Setting Up the Development Environment: Tools and libraries you need to start coding.
Creating the Merkle Tree: How to generate a Merkle tree from a list of whitelisted addresses.
Solidity Implementation: Writing the smart contract to verify Merkle proofs.
Verifying Addresses: Demonstrating how to use Merkle proofs to check if an address is whitelisted.
Testing the Contract: Ensuring our contract works correctly with various test cases.
Enter fullscreen mode Exit fullscreen mode

By the end of this tutorial, you'll have a robust understanding of how to leverage Merkle trees for efficient and secure whitelisting in Solidity smart contracts, providing you with a powerful tool for your future dApp development endeavors.

Understanding Merkle Trees

Merkle trees, named after computer scientist Ralph Merkle, are a type of data structure used in computer science and cryptography to efficiently and securely verify the integrity of large sets of data. In the context of blockchain and decentralized applications, Merkle trees offer significant advantages for managing and verifying data with minimal overhead.
What is a Merkle Tree?

A Merkle tree is a binary tree in which each leaf node represents a hash of a block of data, and each non-leaf node is a hash of its two child nodes. This hierarchical structure ensures that any change in the input data results in a change in the root hash, also known as the Merkle root.

Here’s a simple breakdown of how a Merkle tree is constructed:

Leaf Nodes: Start with hashing each piece of data (e.g., a list of whitelisted addresses).
Intermediate Nodes: Pair the hashes and hash them together to form the next level of nodes.
Root Node: Repeat the process until a single hash remains, known as the Merkle root.
Enter fullscreen mode Exit fullscreen mode

This structure allows for efficient and secure verification of data.
Why Merkle Trees are Useful in Blockchain Applications

Merkle trees are particularly useful in blockchain applications for several reasons:

Efficient Verification: Merkle trees enable the verification of a data element's inclusion in a set without needing to download the entire dataset. This is achieved through a Merkle proof, which is a small subset of hashes from the tree that can be used to verify a particular element against the Merkle root.

Data Integrity: Any alteration in the underlying data will change the hash of the leaf node and, consequently, all the way up to the Merkle root. This makes it easy to detect and prevent tampering with the data.

Scalability: As the size of the dataset grows, Merkle trees allow for efficient handling and verification. This is particularly important in blockchain networks where nodes need to validate transactions and states without extensive computational or storage requirements.

Security: Merkle trees provide cryptographic security by using hash functions that are computationally infeasible to reverse, ensuring that the data structure is tamper-proof and reliable.
Enter fullscreen mode Exit fullscreen mode

Practical Use Cases in Blockchain

Bitcoin and Ethereum: Both Bitcoin and Ethereum use Merkle trees to organize and verify transactions within blocks. In Bitcoin, the Merkle root of all transactions in a block is stored in the block header, enabling efficient transaction verification.

Whitelisting: In smart contracts, Merkle trees can be used to manage whitelisted addresses efficiently. Instead of storing a large list of addresses directly on-chain, a Merkle root can be stored, and users can prove their inclusion in the whitelist with a Merkle proof.
Enter fullscreen mode Exit fullscreen mode

Practice

Enough theory, now it is time to get our hands dirty. We are going to create an empty folder, and run the following command on the terminal to install Hardhat => npm install --save-dev hardhat

Then, with npx hardhat init command, we will start a Hardhat project. For this project, we will use Javascript.

After the project has ben initiated, we will install these following packages also => npm install @openzeppelin/contracts keccak256 merkletreejs fs

Constructing the Merkle Root

In this step, we have a bunch of whitelisted addresses, we will write the script that will construct the merkle tree using those addresses. We will get a JSON file, and a single Merkle Root. We will use that merkle root later on to identify who's whitelisted and who's not.

In the main directory of the project, create utils/merkleTree.js

const keccak256 = require("keccak256");
const { default: MerkleTree } = require("merkletreejs");
const fs = require("fs");

//hardhat local node addresses from 0 to 3
const address = [
  "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
  "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
  //"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
  "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
];
Enter fullscreen mode Exit fullscreen mode

Note that we commented the address number 2.

You see we do not need to manually write the logic for the merkle tree, we're using a library for ease of development. The addresses are the first 4 addresses in Hardhat node. Do not send any money to them, their private keys are publicly known and anything sent to them will be lost.

Now, we will do the following:

  • Hash all individual items in the address array (creating leaves)
  • construct a new merkle tree
//  Hashing All Leaf Individual
//leaves is an array of hashed addresses (leaves of the Merkle Tree).
const leaves = address.map((leaf) => keccak256(leaf));

// Constructing Merkle Tree
const tree = new MerkleTree(leaves, keccak256, {
  sortPairs: true,
});

//  Utility Function to Convert From Buffer to Hex
const bufferToHex = (x) => "0x" + x.toString("hex");

// Get Root of Merkle Tree
console.log(`Here is Root Hash: ${bufferToHex(tree.getRoot())}`);

let data = [];
Enter fullscreen mode Exit fullscreen mode

You see that we're logging the root hash. We will copy it when we run the script.

And now we'll do the following:

  • Push all the proofs and leaves in the data array we've just created
  • Create a whitelist object so that we can write into a JSON file
  • Finally write the JSON file
// Pushing all the proof and leaf in data array
address.forEach((address) => {
  const leaf = keccak256(address);

  const proof = tree.getProof(leaf);

  let tempData = [];

  proof.map((x) => tempData.push(bufferToHex(x.data)));

  data.push({
    address: address,
    leaf: bufferToHex(leaf),
    proof: tempData,
  });
});

// Create WhiteList Object to write JSON file

let whiteList = {
  whiteList: data,
};

//  Stringify whiteList object and formating
const metadata = JSON.stringify(whiteList, null, 2);

// Write whiteList.json file in root dir
fs.writeFile(`whiteList.json`, metadata, (err) => {
  if (err) {
    throw err;
  }
});
Enter fullscreen mode Exit fullscreen mode

Now, if we run node utils/merkleTree.js in the terminal, we will get something like this: Here is Root Hash: 0x12014c768bd10562acd224ac6fb749402c37722fab384a6aecc8f91aa7dc51cf

We'll need this hash later.

We also have a whiteList.json file that should have the following contents:

{
  "whiteList": [
    {
      "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
      "leaf": "0xe9707d0e6171f728f7473c24cc0432a9b07eaaf1efed6a137a4a8c12c79552d9",
      "proof": [
        "0x00314e565e0574cb412563df634608d76f5c59d9f817e85966100ec1d48005c0",
        "0x1ebaa930b8e9130423c183bf38b0564b0103180b7dad301013b18e59880541ae"
      ]
    },
    {
      "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
      "leaf": "0x00314e565e0574cb412563df634608d76f5c59d9f817e85966100ec1d48005c0",
      "proof": [
        "0xe9707d0e6171f728f7473c24cc0432a9b07eaaf1efed6a137a4a8c12c79552d9",
        "0x1ebaa930b8e9130423c183bf38b0564b0103180b7dad301013b18e59880541ae"
      ]
    },
    {
      "address": "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
      "leaf": "0x1ebaa930b8e9130423c183bf38b0564b0103180b7dad301013b18e59880541ae",
      "proof": [
        "0x070e8db97b197cc0e4a1790c5e6c3667bab32d733db7f815fbe84f5824c7168d"
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Verifying the proof in the smart contract

Now, check this Solidity contract out:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";

// Uncomment this line to use console.log
// import "hardhat/console.sol";

contract MerkleProofContract {
    bytes32 public rootHash;

    constructor(bytes32 _rootHash) {
        rootHash = _rootHash;
    }

    function verifyProof(
        bytes32[] calldata proof,
        bytes32 leaf
    ) private view returns (bool) {
        return MerkleProof.verify(proof, rootHash, leaf);
    }

    modifier isWhitelistedAddress(bytes32[] calldata proof) {
        require(
            verifyProof(proof, keccak256(abi.encodePacked(msg.sender))),
            "Not WhiteListed Address"
        );
        _;
    }

    function onlyWhitelisted(
        bytes32[] calldata proof
    ) public view isWhitelistedAddress(proof) returns (uint8) {
        return 5;
    }
}
Enter fullscreen mode Exit fullscreen mode

What it does is the following:

  • Imports Openzeppelin's merkle proof contract
  • Enters the root hash we've just saved in the constructor. This means that there will be no more whitelisted accounts added, and it is final
  • a private verifyProof function invokes Openzeppelin and requires the proof from the user
  • a isWhitelistedAddress modifier makes sure that msg.sender is the whitelisted address. Without this modifier, anyone with the public whitelisted address could call the contract, now, only the owner of the whitelisted address can call
  • a basic onlyWhitelisted function requires the user proof and returns 5. That's is, we just want to see if we can call this function as a non-whitelisted user or not

Testing the contract

Now in the test folder create a MerkleProof.js file and add the following there:

const { expect } = require("chai");
const { formatEther } = require("ethers");
const { ethers } = require("hardhat");

describe("MerkleProof", function () {
  it("only whitelisted address can call function", async function () {
    let owner, addr1, addr2;
    let merkleTreeContract;
    let rootHash =
      "0x12014c768bd10562acd224ac6fb749402c37722fab384a6aecc8f91aa7dc51cf";

    // async function setup() {
    [owner, addr1, addr2] = await ethers.getSigners();

    const MerkleTree = await ethers.getContractFactory("MerkleProofContract");
    merkleTreeContract = await MerkleTree.deploy(rootHash);
    console.log(merkleTreeContract.address);
    // }

    // beforeEach(async function () {
    //   await setup();
    // });

    const user = addr1;

    const proof = [
      "0xe9707d0e6171f728f7473c24cc0432a9b07eaaf1efed6a137a4a8c12c79552d9",
      "0x1ebaa930b8e9130423c183bf38b0564b0103180b7dad301013b18e59880541ae",
    ];

    console.log(
      `user address: ${user.address} and proof: ${proof} and rootHash: ${rootHash}`
    );

    expect(
      await merkleTreeContract.connect(user).onlyWhitelisted(proof)
    ).to.equal(5);

    await expect(
      merkleTreeContract.connect(addr2).onlyWhitelisted(proof)
    ).to.be.revertedWith("Not WhiteListed Address");
  });
});
Enter fullscreen mode Exit fullscreen mode

This test file works as such:

  • owner, addre1 and addr2 are the first 3 addresses in Hardhat node
  • deploys the merkle tree contract with the saved root hash
  • user is addr1, that is the 2nd addess in whiteList.json file. We get the proof from there -connects to a whitelisted user and calls the function, gets the correct value of 5 -connects with a non-whitelisted user (we did comment out the address number 2 at the very beginning ) and calls the function, is reverted.

Hope you enjoyed it! If you have any corrections or suggestions, please let me know in the comments.

Cheers!

💖 💪 🙅 🚩
muratcanyuksel
Murat Can Yüksel

Posted on June 12, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related